feat(procurement): implement sample fulfillment and settled fee workflow
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
|
||||
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
@@ -47,9 +50,9 @@ function assert(context, name, response, passed, evidence = '') {
|
||||
async function merchantLogin(context, fixture) {
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '')
|
||||
const auth = success(response) ? response.body.data : null
|
||||
assert(context, '客户账号同时取得国内和海外短时Token', response,
|
||||
Boolean(auth?.accessToken) && Boolean(auth?.accessToken),
|
||||
`customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`)
|
||||
assert(context, '客户账号取得统一会话与退出凭据', response,
|
||||
Boolean(auth?.accessToken) && Boolean(auth?.refreshToken),
|
||||
`customerId=${fixture.tenantId}, session=${Boolean(auth?.accessToken)}, refresh=${Boolean(auth?.refreshToken)}`)
|
||||
return auth
|
||||
}
|
||||
|
||||
@@ -96,79 +99,46 @@ async function prepareWallet(context, customerId) {
|
||||
return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null
|
||||
}
|
||||
|
||||
async function procurementFlow(context, fixture, token) {
|
||||
const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements'
|
||||
const payload = {
|
||||
customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`,
|
||||
productSpecification: 'full acceptance wallet', productUrl: null, purchaseQuantity: 2,
|
||||
budgetUnitPrice: 1, currency: 'USD', receivingAddress: 'Los Angeles',
|
||||
contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker,
|
||||
idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255),
|
||||
async function procurementFlow(context, fixture, token, warehouseId) {
|
||||
if (!/^[A-Za-z0-9_-]{1,80}$/.test(context.marker)) throw new Error('采购验收批次不适合作为独立证据标识')
|
||||
const directory = new URL('../../reports/procurement-wallet-checkpoints/', import.meta.url)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const output = new URL(`${context.marker}.json`, directory)
|
||||
const procurementContext = Object.create(context)
|
||||
let initialized = false
|
||||
procurementContext.checkpoint = async () => {
|
||||
const proof = context.createdResources.procurementSample || { marker: context.marker, status: 'PREPARING' }
|
||||
await writeFile(output, JSON.stringify(proof, null, 2) + '\n', { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
||||
initialized = true
|
||||
}
|
||||
const created = await context.request('POST', merchantPath, payload, token, {})
|
||||
const detail = created?.body?.data
|
||||
const orderNo = detail?.summary?.orderNo
|
||||
assert(context, '客户创建钱包采购单', created,
|
||||
success(created) && id(detail?.summary?.customerId) === id(fixture.tenantId) && detail?.summary?.status === 'PENDING_REVIEW',
|
||||
`orderNo=${orderNo || 'missing'}, status=${detail?.summary?.status || 'missing'}`)
|
||||
if (!success(created) || !orderNo) return null
|
||||
assert(context, '采购单号按客户拼音首字母加北京时间日期和四位序号生成', created,
|
||||
fixture.expectedProcurementPrefix ? matchesProcurementNo(orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(orderNo),
|
||||
`orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`)
|
||||
|
||||
await procurementContext.checkpoint()
|
||||
const customerId = id(fixture.tenantId)
|
||||
const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), {
|
||||
version: Number(detail.summary.version), decision: 'APPROVE',
|
||||
idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker,
|
||||
})
|
||||
assert(context, '平台审核通过钱包采购单', approved,
|
||||
success(approved) && approved.body.data?.summary?.status === 'APPROVED',
|
||||
`status=${approved.body?.data?.summary?.status || 'missing'}`)
|
||||
if (!success(approved)) return null
|
||||
|
||||
const files = []
|
||||
for (const category of ['VOUCHER', 'RECEIPT']) {
|
||||
const uploaded = await context.requestMultipart('POST',
|
||||
query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
||||
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
||||
assert(context, `采购单上传${category}文件`, uploaded,
|
||||
success(uploaded) && id(uploaded.body.data?.fileId) !== '' && uploaded.body.data?.category === category,
|
||||
`fileId=${id(uploaded.body?.data?.fileId) || 'missing'}`)
|
||||
if (success(uploaded)) files.push(uploaded.body.data)
|
||||
const upload = async (orderNo, category) => {
|
||||
const proof = context.createdResources.procurementSample
|
||||
const entry = { category, orderNo, status: 'SENDING' }
|
||||
proof.uploads ??= []; proof.uploads.push(entry); await procurementContext.checkpoint()
|
||||
let response
|
||||
try {
|
||||
response = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }),
|
||||
{ content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` })
|
||||
} catch (error) { entry.status = 'UNKNOWN_RESULT'; await procurementContext.checkpoint(); throw error }
|
||||
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, code: response?.body?.code, fileId: response?.body?.data?.fileId, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await procurementContext.checkpoint()
|
||||
assert(context, `采购受控上传${category}`, response, success(response) && Boolean(response.body.data?.fileId))
|
||||
if (!success(response) || !response.body.data?.fileId) throw new Error('采购凭证上传未确认成功,不自动重试')
|
||||
return response.body.data
|
||||
}
|
||||
try {
|
||||
const proof = await verifyProcurementSample(procurementContext, { marker: context.marker, customerId,
|
||||
customerName: fixture.tenantName, customerToken: token, warehouseId, upload,
|
||||
readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } })
|
||||
assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`)
|
||||
assert(context, '采购单号日期和客户前缀正确', null,
|
||||
fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`)
|
||||
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
|
||||
} finally {
|
||||
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
|
||||
}
|
||||
if (files.length !== 2) return null
|
||||
|
||||
const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), {
|
||||
feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01,
|
||||
voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker,
|
||||
})
|
||||
const feeNo = fee?.body?.data?.feeNo
|
||||
assert(context, '采购费用创建并开始钱包冻结', fee,
|
||||
success(fee) && Boolean(feeNo) && ['FREEZE_PENDING', 'FROZEN'].includes(fee.body.data?.walletStatus),
|
||||
`feeNo=${feeNo || 'missing'}, walletStatus=${fee.body?.data?.walletStatus || 'missing'}`)
|
||||
if (!success(fee) || !feeNo) return null
|
||||
|
||||
const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId })
|
||||
const frozen = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN')
|
||||
const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
|
||||
assert(context, '采购费用跨区冻结到账', frozen.result,
|
||||
frozenFee?.walletStatus === 'FROZEN', `feeNo=${feeNo}, pollRequests=${frozen.attempts}, walletStatus=${frozenFee?.walletStatus || 'missing'}`)
|
||||
if (frozenFee?.walletStatus !== 'FROZEN') return null
|
||||
|
||||
const current = frozen.result.body.data.summary
|
||||
const settledOrder = await context.request('PATCH', detailPath, {
|
||||
version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity),
|
||||
})
|
||||
assert(context, '采购全量入库推进待出库并触发结算', settledOrder,
|
||||
success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND',
|
||||
`status=${settledOrder.body?.data?.summary?.status || 'missing'}`)
|
||||
const settled = await poll(async () => context.request('GET', detailPath),
|
||||
(response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED')
|
||||
const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo)
|
||||
assert(context, '采购费用跨区结算到账', settled.result,
|
||||
settledFee?.walletStatus === 'SETTLED', `feeNo=${feeNo}, pollRequests=${settled.attempts}, walletStatus=${settledFee?.walletStatus || 'missing'}`)
|
||||
return { orderNo, feeNo, freezePollRequests: frozen.attempts, settlePollRequests: settled.attempts }
|
||||
}
|
||||
|
||||
function relabelPayload(context, warehouseId, source, suffix, chargeAmount) {
|
||||
@@ -276,7 +246,7 @@ async function relabelFlow(context, token, warehouseId, suffix, terminalStatus)
|
||||
|
||||
async function verifyClientWallet(context, token, fixture, wallet, procurement, relabels) {
|
||||
const account = await context.request('GET', `${CLIENT_FINANCE}/account`, undefined, token)
|
||||
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - 0.02)
|
||||
const expectedActual = money(Number(wallet.before.actualBalance) + wallet.rechargeAmount - Number(procurement.settledAmountCny) - 0.01)
|
||||
assert(context, '客户工作台余额与冻结结算结果一致', account,
|
||||
success(account) && money(account.body.data?.actualBalance) === expectedActual
|
||||
&& money(account.body.data?.frozenBalance) === money(wallet.before.frozenBalance)
|
||||
@@ -284,7 +254,7 @@ async function verifyClientWallet(context, token, fixture, wallet, procurement,
|
||||
`expectedActual=${expectedActual}, actual=${account.body?.data?.actualBalance ?? 'missing'}, frozen=${account.body?.data?.frozenBalance ?? 'missing'}, creditUsed=${account.body?.data?.creditUsed ?? 'missing'}`)
|
||||
const ledger = await context.request('GET', `${CLIENT_FINANCE}/account/ledger?page=1&size=100`, undefined, token)
|
||||
const entries = rows(ledger?.body?.data)
|
||||
const businessNos = [procurement.feeNo, ...relabels.map((item) => item.relabelNo)]
|
||||
const businessNos = [...procurement.walletBusinessNos, ...relabels.map((item) => item.relabelNo)]
|
||||
assert(context, '客户账本包含充值、冻结、结算和释放明细', ledger,
|
||||
success(ledger) && entries.some((item) => item.changeType === 'RECHARGE')
|
||||
&& businessNos.every((businessNo) => entries.some((item) => item.sourceNo === businessNo))
|
||||
@@ -357,8 +327,10 @@ export async function run(context) {
|
||||
if (!fixture?.tenantId || !warehouseId || completedFacts.length === 0) return
|
||||
|
||||
const auth = await merchantLogin(context, fixture)
|
||||
if (!auth?.accessToken || !auth?.refreshToken) return
|
||||
try {
|
||||
const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null
|
||||
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null
|
||||
const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken, warehouseId) : null
|
||||
const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null
|
||||
const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null
|
||||
const relabels = [settledRelabel, releasedRelabel].filter(Boolean)
|
||||
@@ -374,4 +346,9 @@ export async function run(context) {
|
||||
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed),
|
||||
deploymentBlocked: steps.some((step) => step.deploymentBlocker && !step.passed),
|
||||
passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
|
||||
} finally {
|
||||
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
|
||||
assert(context, '退出本轮钱包客户会话', logout, success(logout))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user