feat(procurement): implement sample fulfillment and settled fee workflow

This commit is contained in:
2026-10-10 02:19:38 +08:00
parent 040f3bbd93
commit a973be2103
104 changed files with 9823 additions and 714 deletions
@@ -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))
}
}