test: extend eight-business acceptance flows and offline contracts

This commit is contained in:
2026-10-22 15:41:50 +08:00
parent 86889d7e0e
commit 080ca600e6
33 changed files with 1285 additions and 101 deletions
+22 -2
View File
@@ -1,5 +1,25 @@
# Admin 测试环境 HTTP 模块
样品采购履约每次聚合回查均核对原客户、原采购单号和有效版本;创建重放及客户最终详情同样校验身份,串单立即停止后续写入。`procurement-sample-fulfillment-contract.test.mjs` 离线走完整个分次收货、双箱打包、两段交寄及 1 元货款、2 元运费冻结、1.5 元实际运费结算链,另以串单和重放重复扣款验证失败路径;这不代表真实钱包或部署已验收。
货物废弃在同一申请上传照片后,先重复提交同一照片 ID,要求 409/A0409、诊断标识齐全,且申请与库存快照不变,才继续正常 PDA 确认。实际废弃总账必须具有有效流水 ID,匹配原客户、仓库、托盘、SKU 和入仓码,扣减前后数量差为一箱;独立箱级流水仍不能用公共库存总账替代。对应离线反例位于 `cargo-discard-fulfillment-contract.test.mjs`,不额外创建客户、申请或照片。
本土卡车履约每次请求前后保存检查点:总编排提供 `checkpoint` 时沿用其证据持久化,否则独占创建 `reports/us-truck-checkpoints/<marker>.json`。同批次文件已存在即拒绝重跑,不覆盖旧证据;落盘失败不发送请求,发送后未知结果停止后续命令。记录原请求体、拒绝预期、诊断和最终状态,不记录会话凭据。对应磁盘失败、未知响应及新执行覆盖拒绝均有离线合同。
本土卡车默认履约在正式实收和分配前,各用原单增加一条无效命令:逐托箱数超过请求总箱数、重复选同一托盘。必须明确返回参数错误并带诊断标识,随后回查原单数量、状态、历史和版本完全不变,才继续正常链。两托原单仅分配首托时,还尝试进入待派车,必须返回 409/A0409 状态冲突并保留诊断标识,整个原单回查不变后才继续分配第二托;未知结果、无关拒绝或意外接受均停止。此边界不增加客户或业务单据,不替代超托审核。
客户原贴标签收并释放车次资源后,默认 phase5 的 `verifyClientRelabelSettlement` 有限轮询原申请,核对来源单、仓库、原测试费用、客户确认及 `SETTLED`;串单、金额变化、错误状态或超时均失败,证据随原 `relabelFulfillment.settlement` 进入 phase5 检查点,不发送补结算命令。合同位于 `phase5-overseas-fulfillment-contract.test.mjs`。转运链存在源码待回归风险:PDA 完工调用 `settlePdaCompletion`,而 `completeWarehouseTransferFulfillment` 当前只接受 `FROZEN/NOT_REQUIRED`;脚本保留正式收货及结算断言,不能将离线替身通过视为此服务端时序已通过真实验收。
`warehouse-transfer-receipt.mjs` 在默认原转运出库后复用异仓既有公共空闲托盘,要求目的 FBA 一致、主档启用及库位可预留;不足时失败,不创建或修改公共资源。按原出库仓位正式收货,核对完成命令的权威目标、目标库存位置、唯一新 SKU 入库一箱流水、原键重放及客户详情,再有限轮询关联换标 `COMPLETED/SETTLED`。逐请求保存 `reports/warehouse-transfer-receipt-checkpoints/<marker>.json`;未知结果停写,不覆盖同批次,不直接修改资金状态。离线合同为 `warehouse-transfer-receipt-contract.test.mjs`;只补正常已有库存转运链,在途、多来源、部分收货与异常矩阵仍未全覆盖,真实 HTTP 待运行。
`warehouse-transfer-outbound.mjs` 由默认客户业务链在原转运关联换标确认后调用,仅接受登记的新 SKU 实托预留。出库前核对原单和零 SHIP 流水;出库后独立核对同客户、同仓、原入仓码、新 SKU、实际托盘号的唯一扣一箱流水,原键重放后单据及流水均不变,客户回查一致且关联换标同步 `OUTBOUNDED`。不另建转运单或客户;未知结果停写,证据保存于 `reports/warehouse-transfer-outbound-checkpoints/<marker>.json`,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-outbound-contract.test.mjs`;收货及关联换标结算由 warehouse-transfer-receipt.mjs 继续核验,尚未运行真实 HTTP。
已有库存转运的关联换标已复用 `client-relabel-fulfillment.mjs` 接入默认客户业务链:只接受原分配登记的申请、客户和仓位,复用同仓 PDA,审核、冻结、扫码、组托、完工及客户确认后,要求换标为 `WAIT_TRANSFER_OUTBOUND`,原转运为 `ALLOCATED`,唯一有效预留属于实际新托盘、新 SKU 和原入仓码。检查点独立保存 `reports/transfer-relabel-checkpoints/<marker>.json`,包含原转运及关联换标证据。普通贴标按 `TAIL_ORDER` 从尾程订单审核,关联换标按 `RELABEL_ORDER` 从换标单审核;错渠道停止。转运出库已由下述助手衔接;收货和关联换标结算已由收货助手衔接,真实 HTTP 未执行。
`warehouse-transfer-allocation.mjs` 由默认 `phase4-overseas-client-services.mjs` 在本轮已有库存转运审核通过后调用。仅分配原入仓单、同 SKU、同客户同仓的真实可用仓位,核对关联换标申请唯一、同键重放及客户详情一致,达到 `WAIT_RELABEL`。不另建客户或替代转运单;请求前后保存 `reports/warehouse-transfer-checkpoints/<marker>.json`,未知结果停写,同批次禁止覆盖重跑。离线合同为 `warehouse-transfer-allocation-contract.test.mjs`;关联换标完工与转运出库已衔接,收货已由后续助手衔接,尚未运行真实 HTTP。
客户首页贴标原申请已接入默认 `phase4-overseas-dispatch.mjs` 的 `createClientRelabelDispatch`:总入口显式展开 `phase4-overseas-client-services`,从其 `relabelFulfillment` 读取本轮已确认申请;按 RELABEL 来源、申请号、客户、仓库及全部目标托盘精确选货,创建独立原生排车并核验幂等与逐行明细。不能复用 `phase4OverseasRelabel` 另一份样本的通过结果。检查点同时保存客户贴标来源和排车请求;创建后交由默认 phase5-overseas-fulfillment 串行执行装车、同单新箱唛出库核验、正式签收及车次资源释放;先释放主车,再分别履约旧样本和客户原申请,避免固定资源池重叠占用。deliveryExecution 保存独立终态且随 phase5 检查点落盘;签收后由 verifyClientRelabelSettlement 独立只读轮询原申请资金结算;必须同来源、同仓、原测试费用0.01且 SETTLED,真实 HTTP 未运行。合同复用 `phase4-overseas-dispatch-contract.test.mjs`。
`looseboard-fulfillment.mjs` 已由默认 `phase5-overseas-fulfillment` 调用;总入口将 `phase4-overseas-business` 展开为前置,保留同一张待接车散板单。串接余位发布、原单绑定、PDA 实收、独立 STOCK_IN 数量及唯一性核验、原车加货和重新两级审核,再由原有主链核验全部客户库存、SHIP、装车发车和签收,最后回查原散板的 booking、来货、车次、派送和签收箱数达到 COMPLETED。使用同一固定客户、车队资源池及 PDA;测试约定单价25/托,逐托测量明确为合成 TEST 样本。检查点沿用 `reports/phase5-checkpoints/<marker>.json` 并包含散板证据,未知结果停写。离线合同为 `looseboard-fulfillment-contract.test.mjs`;当前只补脚本,真实 HTTP、异常履约及页面仍未验收。
`looseboard-pool-dispatch.mjs` 接受散板原单已运营确认的实收证据,原车零扫码时显式退回审核,经需求池 `arrange-dispatch` 正式排入本车唯一主单,重放核对唯一性并逐行核对原货不丢失、新增实托与箱数一致。保留原容量计划,仅追加本次实收托盘及测量;使用共享排车写入边界,未知结果全批停写。终态为 `BOUND_AWAITING_REVIEW`,由默认 looseboard-fulfillment.mjs 继续重新审核、装车、签收与流水核验。离线合同为 `looseboard-pool-dispatch-contract.test.mjs`。
@@ -10,9 +30,9 @@
`looseboard-pool-binding.mjs` 由默认 looseboard-fulfillment.mjs 经余位准备调用,负责原散板单接车。输入必须是本轮登记的待接单原单、本轮车次及其 `looseboardOffer` 发布证据;核对客户、版本、同仓目的地和容量后,携带 `sourceOrderNo/sourceOrderVersion` 创建询价及确认承运,重放核对唯一性,再回查原单进入待入仓且关联同一 booking/trip。约定单价由调用方明确提供,不使用历史订单或自动创建客户。逐请求通过总入口 checkpoint 保存诊断,未知结果停止,不自动取消。离线用例位于 `looseboard-pool-binding-contract.test.mjs`;完整编排和运行限制见 looseboard-fulfillment.mjs 条目;此助手单独不代表签收。
`us-truck-fulfillment.mjs` 由默认 `phase4-overseas-business.mjs` 衔接本轮已审核的独立本土卡车原单,调用正式实收、分配、待派车、派车、运输和完成接口。正常样本实收一托,逐托箱数取自原订单;每步核对客户、版本、托盘身份、数量及历史,并执行同键重放和详情回查。不再在 finally 取消这张卡车单,失败保留现场,未知写入不重发;请求诊断与结果放在总入口报告的 `phase4OverseasBusiness.truck.fulfillment`。不创建客户、车辆或司机;订单实收托盘属于业务数据。当前只补脚本,尚未真实 HTTP 运行;该独立订单完成不代表车队 POD 签收,超托与部分分配仍待补。离线合同位于 `phase4-overseas-business-contract.test.mjs`。
`us-truck-fulfillment.mjs` 由默认 `phase4-overseas-business.mjs` 衔接本轮已审核的独立本土卡车原单,调用正式实收、分配、待派车、派车、运输和完成接口。原单至少两箱时分为两托(1箱与余量),依次验证部分分配及全部分配;单箱仅实收一托并明确未覆盖部分分配。逐托箱数取自原订单;每步核对客户、版本、托盘身份、数量及历史,并执行同键重放和详情回查。不再在 finally 取消这张卡车单,失败保留现场,未知写入不重发;请求诊断与结果放在总入口报告的 `phase4OverseasBusiness.truck.fulfillment`。不创建客户、车辆或司机;订单实收托盘属于业务数据。当前只补脚本,尚未真实 HTTP 运行;该独立订单完成不代表车队 POD 签收,超托审核仍待补;部分分配已在两托原单默认链覆盖。离线合同位于 `phase4-overseas-business-contract.test.mjs`。
`client-relabel-fulfillment.mjs` 由默认 `phase4-overseas-client-services.mjs` 衔接客户刚创建的同一张单箱贴标申请:审核与资金冻结、PDA箱标/SKU核验、原箱换标组托、合成完工图片、正常完工和客户确认,达到 `READY_FOR_OUTBOUND/WAIT_PRE_DISPATCH`。来源严格匹配本轮入仓码、仓库、余额和实际托盘;固定客户先核验既有余额,不补款。新目标托盘属于本轮换标业务产物。逐请求写入 `reports/client-relabel-checkpoints/<marker>.json`,未知结果停写,同批次不覆盖重跑,完成后保存 `relabelFulfillment.targetPalletIds` 等供同单后续排车使用。此链不等于已签收;后续排车、出库、交付仍待接入。离线合同不代表真实HTTP通过。
`client-relabel-fulfillment.mjs` 由默认 `phase4-overseas-client-services.mjs` 衔接客户刚创建的同一张单箱贴标申请:审核与资金冻结、PDA箱标/SKU核验、原箱换标组托、合成完工图片、正常完工和客户确认,达到 `READY_FOR_OUTBOUND/WAIT_PRE_DISPATCH`。来源严格匹配本轮入仓码、仓库、余额和实际托盘;固定客户先核验既有余额,不补款。新目标托盘属于本轮换标业务产物。逐请求写入 `reports/client-relabel-checkpoints/<marker>.json`,未知结果停写,同批次不覆盖重跑,完成后保存 `relabelFulfillment.targetPalletIds` 等供同单后续排车使用。此助手只到待排车;默认排车及 phase5 后续已衔接同一申请到签收和资源释放,资金结算仍单独核验。离线合同不代表真实HTTP通过。
`cargo-discard-fulfillment.mjs` 为默认 `phase4-overseas-client-services.mjs` 的内部后续:仅衔接本批已批准的单箱一件代发废弃申请,复用同仓 PDA,扫码解析、上传合成测试图片、确认实际废弃、核对公共余额减一箱及唯一 DISCARD 总账,再由客户确认完成,重放两端完成命令核对库存、历史和时间不变。每次请求前保存 `reports/cargo-discard-checkpoints/<marker>.json`,未知结果停写,不自动重跑或清理;不创建客户或公共资源。多来源/多箱边界及独立箱、SKU、箱级流水底层取证仍保留缺口,不能用公共总账替代底层证据。真实 HTTP 以后执行;离线合同只验证编排与断言。
@@ -4,7 +4,7 @@ import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
function harness(fault = '') {
const calls = [], persisted = []
let discarded = false, confirmed = false, repeats = 0
let discarded = false, confirmed = false, repeats = 0, invalidPhotoRejected = false
const proof = { approvedNo: 'CD202610220001', customerId: '101', warehouseId: '201', inboundCode: 'RK-TEST', skuCode: 'SKU-TEST',
status: 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING' }
const task = { discardNo: proof.approvedNo, customerId: '101', overseasWarehouseId: '201', palletNo: 'PALLET-TEST',
@@ -20,6 +20,11 @@ function harness(fault = '') {
else if (path.endsWith('/resolve')) data = task
else if (path.endsWith('/confirm-completion')) { confirmed = true; data = {} }
else if (path.endsWith('/confirm')) {
if (body.photoFileIds.length === 2) {
invalidPhotoRejected = true
return { http: 200, requestId: 'request-test', traceId: fault === 'photo missing trace' ? null : 'trace-test',
body: { code: fault === 'photo accepted' ? '200' : fault === 'photo wrong code' ? 'A0400' : 'A0409', data: null } }
}
if (fault === 'unknown confirm') throw new Error('network unknown')
if (discarded) repeats++
discarded = true
@@ -42,12 +47,19 @@ function harness(fault = '') {
async snapshot() {
const quantity = discarded ? fault === 'no deduction' ? 4 : fault === 'replay deduction' && repeats ? 2 : 3 : 4
const ledger = discarded ? [{ businessOrderNo: proof.approvedNo, businessType: 'DISCARD', deltaPackages: -1,
id: '501', customerId: proof.customerId, overseasWarehouseId: proof.warehouseId, palletNo: task.palletNo,
beforePackages: 4, afterPackages: 3,
skuCode: proof.skuCode, inboundCode: proof.inboundCode }] : []
if (discarded && fault === 'duplicate ledger') ledger.push({ ...ledger[0] })
if (discarded && fault === 'ledger customer') ledger[0].customerId = '999'
if (discarded && fault === 'ledger warehouse') ledger[0].overseasWarehouseId = '999'
if (discarded && fault === 'ledger pallet') ledger[0].palletNo = 'OTHER-PALLET'
if (discarded && fault === 'ledger identity') delete ledger[0].id
if (discarded && fault === 'ledger arithmetic') ledger[0].afterPackages = 4
return { balances: [{ inboundCode: proof.inboundCode, skuCode: proof.skuCode, totalPackages: quantity, availablePackages: quantity }],
ledger, detail: { summary: { discardNo: proof.approvedNo, customerId: proof.customerId, overseasWarehouseId: proof.warehouseId,
status: confirmed ? 'COMPLETED' : discarded ? 'PDA_COMPLETED' : 'APPROVED', totalPackages: 1,
actualPackages: discarded ? 1 : null, customerConfirmedAt: confirmed ? '2026-10-22T12:00:00' : null } } }
actualPackages: discarded || invalidPhotoRejected && fault === 'photo drift' ? 1 : null, customerConfirmedAt: confirmed ? '2026-10-22T12:00:00' : null } } }
} }
return { context, inputs, calls, persisted }
}
@@ -57,7 +69,7 @@ test('discard executes same application through PDA and customer completion with
const result = await verifyCargoDiscardFulfillment(h.context, h.inputs)
assert.equal(result.status, 'HTTP_VERIFIED')
assert.equal(h.inputs.proof.status, 'COMPLETED_HTTP_VERIFIED_CARTON_SQL_PENDING')
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 3)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 4)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm-completion')).length, 2)
assert.equal(h.calls.filter(call => call.path.endsWith('/photos/files')).length, 1)
assert.ok(!JSON.stringify(h.persisted).includes('client-secret'))
@@ -65,7 +77,9 @@ test('discard executes same application through PDA and customer completion with
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /已批准申请|不自动重跑/)
})
for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'replay deduction', 'unknown confirm', 'disk']) {
for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'replay deduction', 'unknown confirm', 'disk',
'ledger customer', 'ledger warehouse', 'ledger pallet', 'ledger identity', 'ledger arithmetic',
'photo accepted', 'photo wrong code', 'photo missing trace', 'photo drift']) {
test(`discard stops on ${fault}`, async () => {
const h = harness(fault)
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs))
@@ -74,7 +88,7 @@ for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'repl
if (fault === 'wrong customer') assert.equal(h.calls.length, 1)
if (fault === 'disk') assert.equal(h.calls.length, 0)
if (fault === 'unknown confirm') {
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 1)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm')).length, 2)
assert.equal(h.inputs.proof.fulfillment.requests.at(-1).status, 'UNKNOWN_RESULT')
}
})
@@ -31,8 +31,8 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'cargo-discard-fulfillment', deploymentBlocker: true })
if (!passed) throw new Error(name)
}
async function command(method, path, body, token, upload = false) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, status: 'SENDING' }
async function command(method, path, body, token, upload = false, expectedConflict = false) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, expectedConflict, status: 'SENDING' }
evidence.requests.push(entry); await persist()
let response
try { response = upload ? await context.requestMultipart(method, path, body, token)
@@ -41,6 +41,11 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
Object.assign(entry, { status: response?.http === 0 || response?.http >= 500 || !response?.body ? 'UNKNOWN_RESULT' : 'RESPONDED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await persist()
if (expectedConflict) {
await require('废弃重复照片明确冲突且保留诊断', [200, 409].includes(response?.http)
&& ['409', 'A0409'].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
return response.body.data
}
await require(`废弃履约 ${method} ${path}`, ok(response))
return response.body.data
}
@@ -74,6 +79,9 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
await require('废弃图片绑定当前托盘', /^[1-9]\d*$/.test(String(photo?.fileId)) && photo.palletNo === task.palletNo)
const body = { lines: [{ itemId: String(task.skus[0].itemId), actualPackages: 1 }], photoFileIds: [String(photo.fileId)],
scannedCartonMarks: [...task.cartonMarks], confirmAllCartons: false }
const beforeInvalid = await capture('beforeDuplicatePhoto')
await command('POST', `${root}/confirm`, { ...body, photoFileIds: [String(photo.fileId), String(photo.fileId)] }, pdaToken, false, true)
await require('重复照片拒绝后申请与库存零漂移', stable(await capture('duplicatePhotoRejected')) === stable(beforeInvalid))
const completedTask = await command('POST', `${root}/confirm`, body, pdaToken)
await require('PDA完成保留原任务身份和实际数量', matchTask(completedTask) && completedTask.palletNo === task.palletNo
&& completedTask.status === 'COMPLETED' && completedTask.skus?.length === 1
@@ -92,7 +100,12 @@ export async function verifyCargoDiscardFulfillment(context, { proof, merchantTo
const movements = completed.ledger.filter(row => row.businessOrderNo === proof.approvedNo)
await require('本申请只有一笔正确废弃总账', !before.ledger.some(row => row.businessOrderNo === proof.approvedNo)
&& movements.length === 1 && movements[0].businessType === 'DISCARD' && Number(movements[0].deltaPackages) === -1
&& movements[0].skuCode === proof.skuCode && movements[0].inboundCode === proof.inboundCode)
&& /^[1-9]\d*$/.test(String(movements[0].id))
&& movements[0].customerId === proof.customerId && movements[0].overseasWarehouseId === proof.warehouseId
&& movements[0].palletNo === task.palletNo
&& movements[0].skuCode === proof.skuCode && movements[0].inboundCode === proof.inboundCode
&& integer(movements[0].beforePackages) && integer(movements[0].afterPackages)
&& Number(movements[0].beforePackages) - Number(movements[0].afterPackages) === 1)
await command('POST', `${root}/confirm`, body, pdaToken)
await require('PDA完成重放库存与单据零漂移', stable(await capture('pdaReplay')) === stable(completed))
const clientPath = `${CLIENT}/${encodeURIComponent(proof.approvedNo)}/confirm-completion`
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
function harness(fault = '') {
function harness(fault = '', transfer = false) {
const calls = [], saved = [], commands = new Set()
const proof = { marker: 'RELABEL-TEST', relabelNo: 'TB202610220001', customerId: '101', warehouseId: '201',
sourcePositionId: '301', sourcePalletNo: 'SOURCE', sourceOrderNo: 'WC202610220001' }
@@ -10,8 +10,9 @@ function harness(fault = '') {
const item = { itemId: '401', expectedPackages: 1, cartonMark: 'BOX-TEST', fbaCartonMark: 'FBA-TEST',
operationType: 'CARTON_AND_SKU', skuItems: [{ skuCode: 'SKU', newSkuCode: 'NEW', quantity: 1 }] }
const detail = () => ({ application: { relabelNo: proof.relabelNo, customerOrderNo: proof.sourceOrderNo,
lastlegOrderId: '1001', reviewChannel: transfer ? 'RELABEL_ORDER' : 'TAIL_ORDER',
overseasWarehouseId: fault === 'wrong warehouse' ? '999' : '201', status,
walletStatus: fault === 'no funds' ? 'PENDING' : 'FROZEN', fulfillmentStatus: status === 'READY_FOR_OUTBOUND' ? 'WAIT_PRE_DISPATCH' : null },
walletStatus: fault === 'no funds' ? 'PENDING' : 'FROZEN', fulfillmentStatus: status === 'READY_FOR_OUTBOUND' ? transfer && fault !== 'wrong fulfillment' ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH' : null },
items: [{ sourceInventoryPositionId: '301', cartonMark: 'BOX-TEST' }] })
const task = () => ({ relabelNo: proof.relabelNo, taskStatus, version, applicationStatus: status,
relabelledPackages: scanned ? 1 : 0, skippedPackages: 0, exceptionPackages: 0, items: [item] })
@@ -24,7 +25,10 @@ function harness(fault = '') {
if (body?.idempotencyKey) commands.add(body.idempotencyKey)
let data
if (path.endsWith('/tenant/profile')) data = { id: fault === 'wrong customer' ? '999' : '101', status: 'ACTIVE' }
else if (path.endsWith('/status')) { status = 'PENDING_PROCESSING'; data = detail() }
else if (path.endsWith('/status') || path.includes('/relabel-review?')) {
assert.equal(path.endsWith('/status'), transfer)
status = 'PENDING_PROCESSING'; data = detail()
}
else if (path.endsWith('/claim')) { taskStatus = 'PROCESSING'; if (!repeated) version++; data = task() }
else if (path.endsWith('/cartons/scan')) { if (!repeated) version++; data = task() }
else if (path.endsWith('/units')) {
@@ -40,6 +44,9 @@ function harness(fault = '') {
else if (path.endsWith('/confirm-completion')) { status = 'READY_FOR_OUTBOUND'; data = detail() }
else if (path.endsWith('/complete')) { status = 'CUSTOMER_CONFIRM_PENDING'; taskStatus = 'WAIT_REVIEW'; data = task() }
else if (path.includes('/relabel-tasks/')) data = task()
else if (path.includes('/warehouse-transfers/')) data = { transferNo: proof.sourceOrderNo, status: 'ALLOCATED', relabelNo: proof.relabelNo,
relabelApplicationId: '801', allocations: [{ status: 'RESERVED', packageCount: 1, palletId: fault === 'wrong target' ? '999' : '501',
inboundCode: 'IN-1', inventoryPositionId: '901', skuCode: 'NEW' }] }
else data = detail()
return { http: 200, body: { code: '200', data } }
},
@@ -50,6 +57,11 @@ function harness(fault = '') {
} }
const inputs = { proof, merchantToken: 'client-secret', pdaToken: 'pda-secret', delay: async () => {},
checkpoint: async () => { if (fault === 'disk') throw new Error('disk full'); saved.push(structuredClone(proof.execution)) } }
if (transfer) {
inputs.transferProof = { ...proof, transferNo: proof.sourceOrderNo, status: 'WAIT_RELABEL', inboundCode: 'IN-1',
relabelApplicationId: '801', allocation: { status: 'ALLOCATED_WAIT_RELABEL' }, relabelFulfillment: proof }
context.createdResources = { phase4OverseasClientServices: { transferFulfillment: inputs.transferProof } }
}
return { context, inputs, calls, saved }
}
@@ -88,3 +100,27 @@ test('foreign-batch relabel is rejected before any HTTP', async () => {
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /本轮客户申请/)
assert.equal(h.calls.length, 0)
})
test('已有库存转运原关联换标完成后回到原转运新SKU预留', async () => {
const h = harness('', true)
await verifyClientRelabelFulfillment(h.context, h.inputs)
assert.equal(h.inputs.transferProof.status, 'ALLOCATED')
assert.equal(h.inputs.proof.execution.status, 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND')
assert.equal(h.inputs.transferProof.outboundAllocations[0].palletId, '501')
})
for (const fault of ['wrong fulfillment', 'wrong target']) {
test(`转运关联换标拒绝 ${fault}`, async () => {
const h = harness(fault, true)
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs))
assert.equal(h.inputs.transferProof.status, 'WAIT_RELABEL')
assert.equal(h.inputs.proof.execution.status, 'FAILED')
})
}
test('未登记转运分配证据不得操作关联换标', async () => {
const h = harness('', true)
h.inputs.transferProof = { ...h.inputs.transferProof }
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /原分配证据/)
assert.equal(h.calls.length, 0)
})
@@ -7,8 +7,17 @@ const count = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Numbe
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 客户原申请的单箱贴标作业链;只准备真实待排车结果,不借其他换标样本。 */
export async function verifyClientRelabelFulfillment(context, { proof, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
/** 原申请单箱贴标作业链;独立贴标准备排车,关联转运恢复原单预留。 */
export async function verifyClientRelabelFulfillment(context, { proof, transferProof, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
// 已有库存转运的换标必须回到原转运预留,不进入普通贴标排车链。
if (transferProof && (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== transferProof
|| transferProof.relabelFulfillment !== proof || transferProof.marker !== context.marker
|| transferProof.status !== 'WAIT_RELABEL' || transferProof.allocation?.status !== 'ALLOCATED_WAIT_RELABEL'
|| transferProof.relabelNo !== proof?.relabelNo || transferProof.transferNo !== proof?.sourceOrderNo
|| transferProof.customerId !== proof?.customerId || transferProof.warehouseId !== proof?.warehouseId
|| transferProof.sourcePositionId !== proof?.sourcePositionId || transferProof.sourcePalletNo !== proof?.sourcePalletNo)) {
throw new Error('转运关联换标缺少原分配证据')
}
if (proof?.marker !== context.marker || !proof?.relabelNo || !merchantToken || !pdaToken
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|| !validId(proof.warehouseId) || !validId(proof.sourcePositionId) || !proof.sourcePalletNo) {
@@ -18,10 +27,10 @@ export async function verifyClientRelabelFulfillment(context, { proof, merchantT
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('贴标批次标识不合法')
const evidence = proof.execution = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/client-relabel-checkpoints/', import.meta.url)
const directory = new URL(transferProof ? '../../reports/transfer-relabel-checkpoints/' : '../../reports/client-relabel-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(transferProof || proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
@@ -61,7 +70,11 @@ export async function verifyClientRelabelFulfillment(context, { proof, merchantT
require('贴标原申请客户、仓库、来源仓位与待审核状态一致', sameApplication(original)
&& original.application.status === 'PENDING_REVIEW' && original.items?.length === 1
&& String(original.items[0].sourceInventoryPositionId) === proof.sourcePositionId)
const reviewed = await command('PUT', `${platform}/status`, { status: 'PENDING_PROCESSING', remark: context.marker }, context.token)
require('贴标审核渠道与业务来源一致', original.application.reviewChannel === (transferProof ? 'RELABEL_ORDER' : 'TAIL_ORDER')
&& validId(original.application.lastlegOrderId))
const reviewPath = transferProof ? `${platform}/status`
: `/api/platform/v1/last-leg/orders/${original.application.lastlegOrderId}/relabel-review?customerId=${encodeURIComponent(proof.customerId)}`
const reviewed = await command('PUT', reviewPath, { status: 'PENDING_PROCESSING', remark: context.marker }, context.token)
require('贴标审核推进同一申请', sameApplication(reviewed) && reviewed.application.status === 'PENDING_PROCESSING')
let funded
for (let attempt = 0; attempt < 31; attempt++) {
@@ -120,12 +133,25 @@ export async function verifyClientRelabelFulfillment(context, { proof, merchantT
const confirm = { idempotencyKey: key('confirm'), remark: context.marker }
const confirmed = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
const finalReplay = await command('POST', `${merchant}/confirm-completion`, confirm, merchantToken)
require('客户确认原申请进入待排车且原键重放零漂移', sameApplication(confirmed)
&& confirmed.application.status === 'READY_FOR_OUTBOUND' && confirmed.application.fulfillmentStatus === 'WAIT_PRE_DISPATCH'
require('客户确认原申请进入对应出库流程且原键重放零漂移', sameApplication(confirmed)
&& confirmed.application.status === 'READY_FOR_OUTBOUND'
&& confirmed.application.fulfillmentStatus === (transferProof ? 'WAIT_TRANSFER_OUTBOUND' : 'WAIT_PRE_DISPATCH')
&& stable(confirmed) === stable(finalReplay))
if (transferProof) {
const transfer = await command('GET', `/api/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferProof.transferNo)}?customerId=${encodeURIComponent(proof.customerId)}`, undefined, context.token)
const active = transfer?.allocations?.filter(row => row.status === 'RESERVED') || []
require('关联换标完成后原转运保留新SKU实托唯一预留', transfer?.transferNo === transferProof.transferNo
&& transfer.status === 'ALLOCATED' && transfer.relabelNo === proof.relabelNo
&& String(transfer.relabelApplicationId) === transferProof.relabelApplicationId
&& active.length === 1 && active[0].packageCount === 1 && String(active[0].palletId) === String(target.palletId)
&& active[0].inboundCode === transferProof.inboundCode && validId(active[0].inventoryPositionId)
&& active[0].skuCode === item.skuItems[0].newSkuCode)
Object.assign(transferProof, { status: 'ALLOCATED', outboundAllocations: active })
}
Object.assign(proof, { status: 'READY_FOR_OUTBOUND', targetPalletIds: [String(target.palletId)],
sourceCartonMarks: [item.cartonMark], relabelCartonMarks: [item.fbaCartonMark].filter(Boolean) })
evidence.status = 'HTTP_VERIFIED_READY_FOR_DISPATCH'; await persist(); return proof
evidence.status = transferProof ? 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' : 'HTTP_VERIFIED_READY_FOR_DISPATCH'
await persist(); return proof
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
}
@@ -37,7 +37,8 @@ export async function dispatchEvidenceContext(context) {
scoped.checkpoint = async () => {
const resources = context.createdResources
const evidence = redact({ marker: context.marker, boundary: resources.dispatchWriteBoundary,
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch })
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch,
clientRelabel: resources.phase4OverseasClientServices?.relabelFulfillment })
await writeFile(`${path}.tmp`, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8')
await rename(`${path}.tmp`, path)
}
@@ -52,7 +52,9 @@ test('focused matrix execution injects real container lifecycle prerequisites an
'phase4-tail-order-controls',
'phase4-overseas-pda',
'phase4-overseas-pda-operations',
'phase4-overseas-client-services',
'phase4-overseas-dispatch',
'phase4-overseas-business',
'phase5-overseas-fulfillment',
'manual-state-matrix',
]
@@ -85,7 +87,9 @@ test('focused splitting and manual groups resolve producers before consumers wit
'phase4-tail-order-controls',
'phase4-overseas-pda',
'phase4-overseas-pda-operations',
'phase4-overseas-client-services',
'phase4-overseas-dispatch',
'phase4-overseas-business',
'phase5-overseas-fulfillment',
'manual-state-matrix',
]
@@ -27,7 +27,9 @@ test('review helper refuses stale fixture before any request', async () => {
test('transfer review integration covers both source types and known blank-reason failure', async () => {
const source = await read('warehouse-transfer-review.mjs')
const caller = await read('phase4-overseas-client-services.mjs')
assert.match(caller, /return verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)/)
assert.match(caller, /if \(!await verifyWarehouseTransferReview\(context, token, order.row.customerId, body, transferNo\)\) return false/)
assert.ok(caller.indexOf('await allocateWarehouseTransfer(') > caller.indexOf('if (!await verifyWarehouseTransferReview('))
assert.ok(caller.indexOf('await receiveWarehouseTransfer(') > caller.indexOf('await outboundWarehouseTransfer('))
for (const value of ['WAIT_PDA_RECEIVING', 'INBOUND_MEASURING', 'CANCELLED',
'REJECTED', 'APPROVED', 'customerId=', 'rejected(denied, 403)', 'rejected(missingScope, 400)',
'rejected(reverse, 409)', 'rejected(emptyReason, 400)', "sourceLastlegOrderIds: []"])
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
@@ -22,12 +23,88 @@ test('fixed partner warehouse is reused and never retired', async () => {
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8')
test('卡车请求先持久化,正常与失败响应均留有证据', async () => {
const context = fixture(), original = context.request.bind(context)
context.request = async (method, path, ...args) => {
if (context.createdResources.phase4OverseasBusiness?.truck?.fulfillment) {
const last = context.savedTruckEvidence.at(-1)?.requests.at(-1)
if (path.includes('/us-truck-orders/TK1')) {
assert.equal(last?.status, 'PREPARED')
assert.equal(last?.method, method)
}
}
return original(method, path, ...args)
}
await run(context)
assert.equal(context.savedTruckEvidence.at(-1).status, 'HTTP_VERIFIED')
assert.ok(context.savedTruckEvidence.some(e => e.requests.some(r => r.expectedRejection && r.status === 'REJECTED')))
})
test('卡车检查点写入失败不发送请求,不能误记未知网络结果', async () => {
const proof = { marker: 'BATCH', orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
let calls = 0
const context = { marker: 'BATCH', createdResources: { phase4OverseasBusiness: { truck: proof } },
request: async () => { calls++; throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } }
await assert.rejects(verifyUsTruckFulfillment(context, proof), /disk full/)
assert.equal(calls, 0)
assert.equal(proof.fulfillment.requests[0].status, 'PREPARED')
assert.equal(proof.fulfillment.status, 'FAILED')
})
test('卡车默认落盘禁止新进程用同批次覆盖失败证据', async () => {
const marker = `TRUCK-OFFLINE-${randomUUID()}`
const file = new URL(`../../reports/us-truck-checkpoints/${marker}.json`, import.meta.url)
let calls = 0
const make = () => {
const proof = { marker, orderNo: 'TRUCK-1', customerId: '101', finalStatus: 'WAITING_INBOUND' }
return { proof, context: { marker, createdResources: { phase4OverseasBusiness: { truck: proof } },
request: async () => { calls++; throw Error('connection lost') } } }
}
try {
const first = make()
await assert.rejects(verifyUsTruckFulfillment(first.context, first.proof), /connection lost/)
const saved = fs.readFileSync(file, 'utf8')
assert.equal(JSON.parse(saved).fulfillment.requests[0].status, 'UNKNOWN_RESULT')
const second = make()
await assert.rejects(verifyUsTruckFulfillment(second.context, second.proof), /EEXIST/)
assert.equal(calls, 1)
assert.equal(fs.readFileSync(file, 'utf8'), saved)
} finally {
if (fs.existsSync(file)) fs.unlinkSync(file)
}
})
for (const action of ['receive-pallets', 'allocate-pallets']) {
for (const fault of ['accepted', 'unknown', 'wrong rejection', 'drift']) {
test(`卡车 ${action} 无效命令拒绝校验: ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
let rejected = false
context.request = async (method, path, body, ...args) => {
if (method === 'POST' && path.includes(`/${action}?`) && body?.idempotencyKey?.endsWith('-invalid')) {
rejected = true
if (fault === 'accepted') return response({})
if (fault === 'unknown') return { http: 0 }
if (fault === 'wrong rejection') return response(null, 'A0403')
}
const result = await original(method, path, body, ...args)
if (fault === 'drift' && rejected && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
result.body.data.summary.version++
}
return result
}
await assert.rejects(run(context))
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
assert.equal(context.calls.some(c => c.method === 'POST' && c.path.includes(`/${action}?`) && !c.body.idempotencyKey.endsWith('-invalid')), false)
})
}
}
for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay drift']) {
test(`truck fulfillment stops and retains original order on ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
let receipts = 0
context.request = async (method, path, ...args) => {
if (method === 'POST' && path.includes('/receive-pallets?')) {
if (method === 'POST' && path.includes('/receive-pallets?') && !args[0]?.idempotencyKey?.endsWith('-invalid')) {
receipts += 1
if (fault === 'network') return { http: 0 }
const result = structuredClone(await original(method, path, ...args))
@@ -48,6 +125,47 @@ for (const fault of ['network', 'wrong customer', 'wrong pallet count', 'replay
})
}
for (const fault of ['accepted', 'wrong code', 'missing trace', 'detail drift']) {
test(`卡车部分分配待派车拒绝检查失败即停止: ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
let rejected = false
context.request = async (method, path, body, ...args) => {
const result = await original(method, path, body, ...args)
if (body?.idempotencyKey?.endsWith('prepare-dispatch-partial')) {
rejected = true
if (fault === 'accepted') result.body.code = 200
if (fault === 'wrong code') result.body.code = 'A0400'
if (fault === 'missing trace') delete result.traceId
} else if (rejected && fault === 'detail drift' && method === 'GET' && path.includes('/us-truck-orders/TK1?')) {
result.body.data.summary.version++
}
return result
}
await assert.rejects(run(context))
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
})
}
for (const fault of ['premature complete', 'unselected allocated', 'selected unchanged']) {
test(`卡车部分分配拒绝错误结果: ${fault}`, async () => {
const context = fixture(), original = context.request.bind(context)
context.request = async (method, path, body, ...args) => {
const result = await original(method, path, body, ...args)
if (method === 'POST' && path.includes('/allocate-pallets?') && body?.idempotencyKey?.endsWith('-0')) {
if (fault === 'premature complete') result.body.data.summary.status = 'ALLOCATED'
if (fault === 'unselected allocated') result.body.data.pallets[1].status = 'ALLOCATED'
if (fault === 'selected unchanged') result.body.data.pallets[0].status = 'RECEIVED'
}
return result
}
await assert.rejects(run(context))
assert.equal(context.createdResources.phase4OverseasBusiness.truck.fulfillment.status, 'FAILED')
assert.equal(context.calls.some(c => c.path.includes('/prepare-dispatch?')), false)
assert.equal(context.calls.some(c => c.body?.idempotencyKey?.endsWith('allocate-pallets-1')), false)
})
}
test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => {
const context = fixture(), request = context.request.bind(context), pages = []
context.options = { fixedCustomerId: '10001' }
@@ -95,6 +213,11 @@ function fixture(options = {}) {
const context = {
marker, calls, steps, gaps, blockers,
savedTruckEvidence: [],
async checkpoint() {
if (this.createdResources.phase4OverseasBusiness?.truck?.fulfillment)
this.savedTruckEvidence.push(structuredClone(this.createdResources.phase4OverseasBusiness.truck.fulfillment))
},
context: { merchantQualificationFixture: credentials },
createdResources: { merchantQualificationFixture: { tenantId: '10001', marker } },
async request(...args) {
@@ -176,21 +299,32 @@ function fixture(options = {}) {
dispatch: ['PENDING_DISPATCH', 'DISPATCHED'], 'start-transit': ['DISPATCHED', 'IN_TRANSIT'], complete: ['IN_TRANSIT', 'COMPLETED'] }
const action = requestPath.match(/\/us-truck-orders\/TK1\/([^?]+)\?/)?.[1]
if (method === 'POST' && stages[action]) {
if (action === 'prepare-dispatch' && state.truck.status === 'PARTIALLY_ALLOCATED') {
assert.equal(body.version, state.truck.version)
return response(null, 'A0409')
}
const existing = state.truckStatuses.get(body.idempotencyKey)
if (existing) return response(truckDetail())
const [from, to] = stages[action]
let [from, to] = stages[action]
if (action === 'allocate-pallets') {
from = state.truck.allocatedPalletCount === 0 ? 'WAITING_CONSOLIDATION' : 'PARTIALLY_ALLOCATED'
to = state.truck.allocatedPalletCount === 0 ? 'PARTIALLY_ALLOCATED' : 'ALLOCATED'
}
assert.equal(state.truck.status, from)
assert.equal(body.version, state.truck.version)
if (action === 'receive-pallets') {
assert.deepEqual(body.palletPackageCounts, [2])
assert.equal(body.actualPalletCount, 1)
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 2, status: 'RECEIVED' }]
state.truck.receivedPalletCount = 1
if (body.palletPackageCounts.reduce((sum, count) => sum + count, 0) !== body.packageCount) return response(null, 'A0400')
assert.deepEqual(body.palletPackageCounts, [1, 1])
assert.equal(body.actualPalletCount, 2)
state.truckPallets = [{ id: '701', palletNo: 'TK1-P001', packageCount: 1, status: 'RECEIVED' },
{ id: '702', palletNo: 'TK1-P002', packageCount: 1, status: 'RECEIVED' }]
state.truck.receivedPalletCount = 2
}
if (action === 'allocate-pallets') {
assert.deepEqual(body.palletIds, ['701'])
state.truckPallets[0].status = 'ALLOCATED'
state.truck.allocatedPalletCount = 1
if (new Set(body.palletIds).size !== body.palletIds.length) return response(null, 'A0400')
assert.deepEqual(body.palletIds, [state.truck.allocatedPalletCount === 0 ? '701' : '702'])
state.truckPallets.find(row => row.id === body.palletIds[0]).status = 'ALLOCATED'
state.truck.allocatedPalletCount++
}
state.truck.status = to; state.truck.version += 1
state.truckHistories.push({ fromStatus: from, toStatus: to, idempotencyKey: body.idempotencyKey })
@@ -325,7 +459,9 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.notEqual(procurementReviews[3].body.idempotencyKey, procurementReviews[0].body.idempotencyKey)
const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders'))
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 19)
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 24)
assert.equal(truckCalls.filter(call => call.body?.idempotencyKey?.endsWith('prepare-dispatch-partial')).length, 1)
assert.equal(truckCalls.filter(call => call.method === 'POST' && call.body?.idempotencyKey?.endsWith('-invalid')).length, 2)
assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
const truckReviews = truckCalls.filter((call) => call.method === 'POST' && call.path.includes('/review?'))
assert.equal(truckReviews.length, 4)
@@ -356,6 +492,8 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.equal(registry.truck.finalStatus, 'COMPLETED')
assert.equal(registry.truck.retained, true)
assert.equal(registry.truck.fulfillment.status, 'HTTP_VERIFIED')
assert.equal(registry.truck.fulfillment.partialAllocationVerified, true)
assert.equal(registry.truck.fulfillment.pallets.length, 2)
assert.equal(registry.looseboard.finalStatus, 'PENDING_ACCEPTANCE')
assert.equal(registry.looseboard.retained, true)
assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE')
@@ -97,7 +97,8 @@ test('module records marker-owned resources without forbidden fulfillment cleanu
assert.match(source, /relabelNo = relabelNo/)
assert.match(source, /transferNo = transferNo/)
assert.match(source, /discardNo = discardNo/)
assert.match(source, /no dispatch or delivery action is invoked/)
assert.match(source, /不自动删除业务数据/)
assert.match(source, /transfer: transferDone \? context\.createdResources\.phase4OverseasClientServices\.transferFulfillment\?\.status \?\? null : null/)
assert.match(source, /await verifyCargoDiscardFulfillment\(context/)
assert.match(source, /snapshot: \(\) => discardSnapshot\(context, token, proof, proof.approvedNo\)/)
})
@@ -1,4 +1,7 @@
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.mjs'
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
import { fundRelabelWallet } from './phase4-overseas-pda-operations.mjs'
@@ -171,9 +174,9 @@ async function verifyRelabel(context, token, order) {
try {
if (!picker?.token) throw new Error('贴标后续未取得本仓PDA会话')
await verifyClientRelabelFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token })
context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
'原申请已完成PDA贴标和客户确认,后续必须使用本申请targetPalletIds接履约,不能借其他换标单结果',
'not-implemented', { blocking: false })
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'client-relabel-delivery', '客户原贴标申请的排车至交付',
'当前定向范围仅到客户确认;完整默认链继续使用本申请targetPalletIds排车并签收,不能借其他换标单结果',
'fixture', { blocking: false })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 94)
}
@@ -260,7 +263,30 @@ async function verifyTransfer(context, token, orders) {
&& detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0,
`status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.transferNo = transferNo
return verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)
if (!await verifyWarehouseTransferReview(context, token, order.row.customerId, body, transferNo)) return false
const transferProof = context.createdResources.phase4OverseasClientServices.transferFulfillment = {
marker: context.marker, customerId: id(order.row.customerId), warehouseId: id(order.row.overseasWarehouseId),
transferNo, inboundCode: order.row.inboundCode, skuCode: cargo.skuCode, status: 'APPROVED',
}
await allocateWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
const relabelProof = transferProof.relabelFulfillment = {
marker: context.marker, relabelNo: transferProof.relabelNo, customerId: transferProof.customerId,
warehouseId: transferProof.warehouseId, sourceOrderNo: transferNo,
sourcePositionId: transferProof.sourcePositionId, sourcePalletNo: transferProof.sourcePalletNo,
status: 'PENDING_REVIEW',
}
const access = await resolvePickerAccess(context)
if (!access) throw new Error('转运换标后续缺少有效PDA角色')
const picker = await createPicker(context, 96, transferProof.warehouseId, access)
try {
if (!picker?.token) throw new Error('转运换标未取得本仓PDA会话')
await verifyClientRelabelFulfillment(context, { proof: relabelProof, transferProof, merchantToken: token, pdaToken: picker.token })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 96)
}
await outboundWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
await receiveWarehouseTransfer(context, { proof: transferProof, merchantToken: token })
return true
}
export async function verifyDiscard(context, token, markerOrders) {
@@ -425,7 +451,7 @@ async function runWithSession(context, token) {
context.createdResources.phase4OverseasClientServices = {
marker: context.marker, relabelNo: null, transferNo: null, discardNo: null,
finalStatuses: { relabel: null, transfer: null, discard: null },
cleanup: 'Relabel ready for dispatch; transfer review fixtures retained; discard PDA execution and customer confirmation retained; no dispatch or delivery action is invoked',
cleanup: '保留原贴标申请、转运分配换标出入库及废弃完成证据;不自动删除业务数据;贴标排车签收由后续模块执行',
}
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
@@ -448,7 +474,7 @@ async function runWithSession(context, token) {
const discardDone = await verifyDiscard(context, token, orders)
context.createdResources.phase4OverseasClientServices.finalStatuses = {
relabel: relabelDone ? 'READY_FOR_OUTBOUND' : null,
transfer: transferDone ? 'APPROVED' : null,
transfer: transferDone ? context.createdResources.phase4OverseasClientServices.transferFulfillment?.status ?? null : null,
discard: discardDone ? 'COMPLETED' : null,
}
}
@@ -4,8 +4,8 @@ import { readFileSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve, basename } from 'node:path'
import { DISPATCH_BASE as BASE, dispatchEvidenceContext } from './grouped-dispatch-acceptance.mjs'
import { run, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
import { DISPATCH_BASE as BASE, dispatchEvidenceContext, dispatchIo } from './grouped-dispatch-acceptance.mjs'
import { run, createClientRelabelDispatch, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
const clone = value => structuredClone(value)
const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) },
@@ -46,7 +46,8 @@ function server(fault = '', initial = false) {
if (method === 'POST' && path === `${BASE}/drafts`) { const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', sources: [], containers: [] }; drafts.set(d.draftToken, d); return remember(d) }
if (method === 'GET' && path === `${BASE}/drafts`) return response([...drafts.values()].filter(d => d.status === 'ACTIVE'))
if (path === `${BASE}/candidates`) {
assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED')
assert.equal(params.get('warehouseId'), '44')
assert.equal(params.get('sourceStage'), params.get('orderNo') === 'RL-CLIENT' ? 'RELABEL' : 'RECEIVED')
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' })
@@ -97,6 +98,37 @@ function server(fault = '', initial = false) {
return { context, candidates, calls, checks, orders, bundle: () => { const o = orders.get('DS-1'); return { data: view(o), warehouseId: '44', items: { items: clone(o.items) }, route: { stops: stops(o) } } } }
}
function addClientRelabel(h) {
const candidate = clone(rows()[0])
Object.assign(candidate, { sourceKey: 'RELABEL:RL-CLIENT:91', availablePackages: 1 })
candidate.selection = { sourceType: 'RELABEL', relabelNo: 'RL-CLIENT', warehousePalletId: '91', sourceVersion: candidate.sourceVersion }
h.candidates.push(candidate)
h.context.createdResources.phase4OverseasClientServices = { relabelFulfillment: { marker: 'CURRENT', relabelNo: 'RL-CLIENT',
status: 'READY_FOR_OUTBOUND', execution: { status: 'HTTP_VERIFIED_READY_FOR_DISPATCH' }, customerId: '55', warehouseId: '44', targetPalletIds: ['91'] } }
}
test('client original relabel gets its own exact dispatch and refuses repeat execution', async () => {
const h = server(); addClientRelabel(h)
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'CLIENT-RELABEL')
await createClientRelabelDispatch(h.context, io, proof)
assert.equal(proof.clientRelabel.status, 'CREATED_BEFORE_LOADING')
assert.equal(h.context.createdResources.phase4OverseasClientServices.relabelFulfillment.dispatchNo, 'DS-1')
assert.deepEqual(proof.clientRelabel.sourceKeys, ['RELABEL:RL-CLIENT:91'])
const writes = h.calls.filter(c => c.method === 'POST').length
await assert.rejects(createClientRelabelDispatch(h.context, io, proof))
assert.equal(h.calls.filter(c => c.method === 'POST').length, writes)
})
for (const fault of ['customer', 'pallet', 'missing']) test(`client relabel excludes ${fault} candidates before writes`, async () => {
const h = server(); addClientRelabel(h)
if (fault === 'customer') h.candidates.at(-1).customerId = '99'
if (fault === 'pallet') h.candidates.at(-1).selection.warehousePalletId = '99'
if (fault === 'missing') h.candidates.pop()
const proof = {}, io = dispatchIo(h.context, 'test', proof, 'CLIENT-RELABEL')
await assert.rejects(createClientRelabelDispatch(h.context, io, proof))
assert.equal(h.calls.some(c => c.method === 'POST'), false)
})
test('native route fixture uses real physical pallets and requires two destinations plus a repeated destination', () => {
const c = rows(), selected = selectDispatchRouteFixture(c)
assert.deepEqual(selected.selected.map(c => c.sourceKey), ['PLAN:21', 'PLAN:22']); assert.equal(selected.duplicate.sourceKey, 'PLAN:23')
@@ -139,7 +171,7 @@ test('phase4 missing current marker or real multi-destination fixture is explici
test('complete offline phase4 hands off the actual native Detail and current source keys at BEFORE_LOADING', async () => {
const directory = await mkdtemp(join(tmpdir(), 'phase4-dispatch-contract-'))
try {
const h = server(); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
const h = server(); addClientRelabel(h); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
h.context.createdResources.dispatchWriteBoundary = { marker: 'CURRENT', stopped: false, password: 'PRIVATE_PASSWORD', token: 'PRIVATE_LOGIN_TOKEN' }
const originalRequest = h.context.request
h.context.request = async (method, path, body) => {
@@ -151,6 +151,39 @@ async function createRelabel(context, io, proof) {
Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data })
proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' }
}
/** 客户首页贴标原申请独立排车;不可借用旧PDA换标样本的通过结果。 */
export async function createClientRelabelDispatch(context, io, proof) {
const fixture = context.createdResources?.phase4OverseasClientServices?.relabelFulfillment
io.check('客户贴标排车前置为本轮已确认原申请', null, fixture?.marker === context.marker
&& fixture.status === 'READY_FOR_OUTBOUND' && fixture.execution?.status === 'HTTP_VERIFIED_READY_FOR_DISPATCH'
&& fixture.relabelNo && fixture.customerId && fixture.warehouseId && fixture.targetPalletIds?.length > 0
&& new Set(fixture.targetPalletIds.map(id)).size === fixture.targetPalletIds.length && !fixture.dispatchNo && !proof.clientRelabel)
const evidence = proof.clientRelabel = { marker: context.marker, relabelNo: fixture.relabelNo, status: 'CREATING' }
try {
const candidates = (await candidatePage(io, fixture.warehouseId,
{ orderNo: fixture.relabelNo, sourceStage: 'RELABEL', planIds: [] }))
.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo
&& id(c.customerId) === id(fixture.customerId) && id(c.warehouseId) === id(fixture.warehouseId))
io.check('客户原申请目标托盘候选完整且唯一', null, candidates.length === fixture.targetPalletIds.length
&& new Set(candidates.map(c => id(c.selection.warehousePalletId))).size === candidates.length
&& candidates.every(c => fixture.targetPalletIds.map(id).includes(id(c.selection.warehousePalletId))))
const body = createBody(context.marker, 'CLIENT-RELABEL', candidates)
evidence.createRequest = body
const created = await io.command('POST', BASE, body, '客户贴标原申请创建独立排车')
io.check('客户原申请排车返回业务单号', null, typeof created?.dispatchNo === 'string' && created.dispatchNo.length > 0)
evidence.dispatchNo = created.dispatchNo
await replay(io, BASE, body, created, '客户原贴标申请排车创建重放')
const current = await readDispatch(io, created.dispatchNo)
io.check('客户贴标排车逐行匹配原申请实托与箱数', null, dispatchMatches(current, candidates))
Object.assign(fixture, { dispatchNo: created.dispatchNo, dispatchStage: current.data.stage })
Object.assign(evidence, { status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey) })
await context.checkpoint()
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message
await context.checkpoint(); throw error
}
}
export async function run(context) {
context.createdResources ??= {}
if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建')
@@ -193,6 +226,7 @@ export async function run(context) {
sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' })
io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING')
await createRelabel(context, io, proof)
await createClientRelabelDispatch(context, io, proof)
await context.checkpoint()
} catch (error) {
if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message)
@@ -1,7 +1,7 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext, verifyRelabelDispatchOutbound, verifyClientRelabelSettlement } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -10,6 +10,80 @@ import { fileURLToPath } from 'node:url'
import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
for (const fault of ['', 'foreign customer', 'old marks', 'sign failure', 'resource held', 'settlement failed']) {
test(`client original relabel delivery includes receipt and resource release: ${fault || 'normal'}`, async () => {
const relabel = { marker: 'batch', relabelNo: 'RL-CLIENT', dispatchNo: 'DP-CLIENT', warehouseId: '42', customerId: '101',
sourceCartonMarks: ['OLD'], relabelCartonMarks: ['NEW'] }
const calls = []
const context = { marker: 'batch', createdResources: { phase4OverseasClientServices: { relabelFulfillment: relabel } },
checkpoint: async () => {}, assert(_m, name, _r, ok) { assert.ok(ok, name) },
apiSuccess(_m, _name, response) { return response.body.data },
async request(method, path) {
calls.push('outbound')
assert.equal(method, 'GET'); assert.ok(path.endsWith('/outbound-orders/OUT-1'))
return { http: 200, body: { code: '200', data: { outbound: { outboundNo: 'OUT-1', dispatchNo: 'DP-CLIENT', customerId: '101',
status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ cartonMarks: fault === 'old marks' ? 'OLD' : 'NEW' }] } } }
} }
const helpers = {
async prepare(_c, _m, dispatch, warehouse) { calls.push('prepare'); assert.equal(dispatch, relabel.dispatchNo); assert.equal(warehouse, '42'); return { tripNo: 'TR-1' } },
async pick() { calls.push('pick'); return true }, async load() { calls.push('load') },
async read() { calls.push('read'); return { outbounds: [{ customerId: '101', outboundNo: 'OUT-1' }],
deliveries: [{ customerId: fault === 'foreign customer' ? '102' : '101', outboundNo: 'OUT-1', deliveryNo: 'DE-1', packageCount: 1 }] } },
async sign() { calls.push('sign'); return { order: { status: fault === 'sign failure' ? 'DELIVERING' : 'DELIVERED',
deliveryNo: 'DE-1', customerId: '101', outboundNo: 'OUT-1' } } },
async finish() { calls.push('finish'); return { status: fault === 'resource held' ? 'CHECKING' : 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
async settle() { calls.push('settle'); return { status: fault === 'settlement failed' ? 'FAILED' : 'SETTLED_HTTP_VERIFIED' } },
}
if (fault) {
await assert.rejects(verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers))
assert.equal(relabel.deliveryExecution.status, 'FAILED')
if (['foreign customer', 'old marks'].includes(fault)) assert.equal(calls.includes('sign'), false)
if (fault === 'sign failure') assert.equal(calls.includes('finish'), false)
} else {
await verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers)
assert.equal(relabel.deliveryExecution.status, 'DELIVERED_HTTP_VERIFIED')
assert.deepEqual(calls, ['prepare', 'pick', 'load', 'read', 'outbound', 'sign', 'finish', 'settle'])
}
const count = calls.length
await assert.rejects(verifyRelabelDispatchOutbound(context, relabel, 'pda', '42', helpers))
assert.equal(calls.length, count)
})
}
for (const fault of ['', 'foreign order', 'wrong amount', 'pending', 'rejected']) {
test(`原贴标结算只读回查: ${fault || 'settled'}`, async () => {
const relabel = { marker: 'batch', relabelNo: 'RL-1', sourceOrderNo: 'WC-1', customerId: '101', warehouseId: '42', outboundNo: 'OUT-1',
deliveryExecution: { closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } } }
let calls = 0
const context = { marker: 'batch', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { relabelFulfillment: relabel } }, checkpoint: async () => {}, assert() {},
apiSuccess(_m, _n, response) { return response.body.data },
async request(method, path) {
assert.equal(method, 'GET'); assert.ok(path.endsWith('/relabel-applications/RL-1')); calls++
return { http: 200, body: { code: '200', data: { application: { relabelNo: 'RL-1',
customerOrderNo: fault === 'foreign order' ? 'OTHER' : 'WC-1', overseasWarehouseId: '42',
chargeAmount: fault === 'wrong amount' ? 1 : 0.01, chargeCurrency: 'USD', customerConfirmedAt: '2026-10-22T01:00:00',
walletStatus: fault === 'pending' ? 'SETTLE_PENDING' : fault === 'rejected' ? 'SETTLE_FAILED' : calls === 1 ? 'SETTLE_PENDING' : 'SETTLED' } } } }
} }
if (fault) {
await assert.rejects(verifyClientRelabelSettlement(context, relabel, async () => {}))
assert.equal(relabel.settlement.status, 'FAILED')
assert.equal(calls, fault === 'pending' ? 31 : 1)
} else {
await verifyClientRelabelSettlement(context, relabel, async () => {})
assert.equal(relabel.settlement.status, 'SETTLED_HTTP_VERIFIED'); assert.equal(calls, 2)
}
})
}
test('relabel fleets run after main resource release and retain both original proofs in checkpoints', () => {
const start = source.indexOf('export async function run(context)')
const run = source.slice(start)
assert.ok(run.indexOf('await finishRelabelDeliveries(') > run.indexOf('await finishFleetTrip('))
assert.equal(run.includes('await verifyRelabelDispatchOutbound('), false)
assert.match(source, /clientRelabel: context.createdResources.phase4OverseasClientServices\?\.relabelFulfillment/)
})
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => {
@@ -47,6 +47,8 @@ export async function fulfillmentEvidenceContext(context) {
await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence,
fulfillment: context.createdResources.phase5OverseasFulfillment,
looseboard: context.createdResources.phase4OverseasBusiness?.looseboard,
clientRelabel: context.createdResources.phase4OverseasClientServices?.relabelFulfillment,
pdaRelabel: context.createdResources.phase4OverseasRelabel,
fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8')
await rename(`${path}.tmp`, path)
}
@@ -844,26 +846,107 @@ export function relabelOutboundMarksMatch(detail, relabel) {
&& Number(detail?.outbound?.packageCount) === expected.length
}
async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId) {
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
return
// 客户原贴标费用只读等待结算回执,不以派送成功推定扣费成功。
export async function verifyClientRelabelSettlement(context, relabel, delay = () => new Promise(resolve => setTimeout(resolve, 1000))) {
if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment !== relabel
|| relabel?.marker !== context.marker || !nonBlank(relabel.sourceOrderNo)
|| id(relabel.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId)
|| relabel.deliveryExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|| !nonBlank(relabel.outboundNo)) throw Error('贴标结算缺少本轮原申请已交付证据')
const evidence = relabel.settlement = { status: 'POLLING', observations: [] }
try {
for (let attempt = 0; attempt < 31; attempt++) {
await context.checkpoint()
const result = await platform(context, 'GET', '客户原贴标申请结算回查',
`${LASTLEG_BASE}/relabel-applications/${encodeURIComponent(relabel.relabelNo)}`)
const application = result.data?.application
const same = success(result.response) && application?.relabelNo === relabel.relabelNo
&& application.customerOrderNo === relabel.sourceOrderNo && id(application.overseasWarehouseId) === id(relabel.warehouseId)
&& Number(application.chargeAmount) === 0.01 && nonBlank(application.chargeCurrency)
&& Boolean(application.customerConfirmedAt)
context.assert(MODULE, '结算回执属于原贴标申请及原费用', result.response, same, `relabelNo=${relabel.relabelNo}`, OPTIONS)
if (!same) throw Error('原贴标申请结算身份或金额不一致')
evidence.observations.push({ walletStatus: application.walletStatus, chargeAmount: application.chargeAmount,
chargeCurrency: application.chargeCurrency, requestId: result.response.requestId, traceId: result.response.traceId })
await context.checkpoint()
if (application.walletStatus === 'SETTLED') { evidence.status = 'SETTLED_HTTP_VERIFIED'; await context.checkpoint(); return evidence }
if (!['FROZEN', 'SETTLE_PENDING'].includes(application.walletStatus)) throw Error(`贴标结算异常状态: ${application.walletStatus}`)
if (attempt < 30) await delay()
}
throw Error('原贴标费用结算回执等待超时')
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await context.checkpoint(); throw error }
}
// 原换标申请逐车完成,必须等前车释放复用资源,不能只出库不签收。
export async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId, helpers = {
prepare: prepareFleetTrip, pick: pickAll, load: confirmFleetLoading, read: readAutomaticDispatchDelivery,
sign: saveReceiptAndComplete, finish: finishFleetTrip, settle: verifyClientRelabelSettlement,
}) {
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo) || !token || relabel.deliveryExecution
|| ![context.createdResources?.phase4OverseasRelabel, context.createdResources?.phase4OverseasClientServices?.relabelFulfillment].includes(relabel)
|| id(relabel.warehouseId ?? relabel.overseasWarehouseId) !== id(warehouseId)) throw Error('缺少本轮原换标排车、同仓PDA或履约已执行')
const evidence = relabel.deliveryExecution = { status: 'RUNNING', marker: context.marker }
const require = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabel.relabelNo}`, OPTIONS)
if (!passed) throw Error(name)
}
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
if (!await pickAll(context, token, relabel.dispatchNo)) return
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo)
if (!transferred) return
try {
const proof = await helpers.prepare(context, MODULE, relabel.dispatchNo, warehouseId)
evidence.tripNo = proof.tripNo
require('原换标排车实际扫码完成', await helpers.pick(context, token, relabel.dispatchNo))
await helpers.load(context, MODULE, proof, token, relabel.loadingMeasurements)
const transferred = await helpers.read(context, relabel.dispatchNo)
const customerId = id(relabel.customerId)
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
if (!nonBlank(outboundNo)) { blocker(context, '换标排车客户出库', '全车安排结果缺少本轮换标客户出库单'); return }
require('原换标排车生成唯一客户出库且派送无外来客户', transferred?.outbounds?.length === 1
&& id(transferred.outbounds[0].customerId) === customerId && nonBlank(transferred.outbounds[0].outboundNo)
&& transferred.deliveries?.length > 0 && new Set(transferred.deliveries.map(row => row.deliveryNo)).size === transferred.deliveries.length
&& transferred.deliveries.every(row => id(row.customerId) === customerId && row.outboundNo === transferred.outbounds[0].outboundNo)
&& transferred.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === relabel.relabelCartonMarks.length)
const outboundNo = transferred.outbounds[0].outboundNo
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`)
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
require('排车出库快照精确保留全部新箱唛且无原箱唛或重复箱',
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
&& relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS)
&& id(detail.data?.outbound?.customerId) === customerId && detail.data?.outbound?.status === 'OUTBOUNDED'
&& relabelOutboundMarksMatch(detail.data, relabel))
Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() })
for (const [index, delivery] of transferred.deliveries.entries()) {
const signed = await helpers.sign(context, delivery, customerId, index)
require('原换标货物经正式签收完成派送', signed?.order?.status === 'DELIVERED'
&& signed.order.deliveryNo === delivery.deliveryNo && id(signed.order.customerId) === customerId
&& signed.order.outboundNo === outboundNo)
}
evidence.closure = await helpers.finish(context, MODULE, proof)
require('原换标车签收后车辆司机均已释放', evidence.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
if (context.createdResources?.phase4OverseasClientServices?.relabelFulfillment === relabel) {
const settled = await helpers.settle(context, relabel)
require('客户原贴标申请费用已结算', settled?.status === 'SETTLED_HTTP_VERIFIED')
}
evidence.status = 'DELIVERED_HTTP_VERIFIED'
await context.checkpoint()
return evidence
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message
await context.checkpoint(); throw error
}
}
// 主车结案后串行复用同仓PDA及车辆池,客户原申请与旧换标样本分别核验。
async function finishRelabelDeliveries(context, mainTrip, warehouseId) {
if (mainTrip.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('主车尚未释放,不能启动换标履约')
const access = await resolvePickerAccess(context)
const picker = access ? await createPicker(context, 95, warehouseId, access) : null
try {
if (!picker?.token) throw Error('换标完整履约缺少可复用同仓PDA')
for (const original of [context.createdResources.phase4OverseasRelabel, context.createdResources.phase4OverseasClientServices?.relabelFulfillment]) {
await verifyRelabelDispatchOutbound(context, original, picker.token, warehouseId)
}
} finally {
await disablePicker(context, picker, 95)
if (picker) picker.token = null
await context.checkpoint()
}
}
async function loginMerchant(context, customerId) {
@@ -1144,8 +1227,6 @@ export async function run(context) {
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS)
if (!unchanged) return
}
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, picker.token, warehouseId)
} finally {
try { await disablePicker(context, picker, 5) }
finally { if (picker) picker.token = null; await context.checkpoint() }
@@ -1266,6 +1347,7 @@ export async function run(context) {
// 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。
if (context.context?.phase5TailVerification?.runReturnScenario !== true) {
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
await finishRelabelDeliveries(context, tripProof, warehouseId)
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'
@@ -1315,6 +1397,7 @@ export async function run(context) {
exceptionNo: returnExceptionNo, outboundNo })
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
await finishRelabelDeliveries(context, tripProof, warehouseId)
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
@@ -48,3 +48,95 @@ test('failure to persist the request prevents network dispatch', async () => {
await assert.rejects(verifyProcurementSample(context, inputs), /disk write failed/)
assert.equal(events.length, 0)
})
function completeFixture(fault = '') {
const { context, inputs } = fixture()
const calls = [], seen = new Map()
let spent = 0, frozen = 0, creations = 0
const order = { orderNo: 'CG-TEST-1', customerId: '101', status: 'PENDING_REVIEW', version: 0, updatedAt: '2026-10-22T12:00:00' }
const fees = [], physical = { receipts: [], cartons: [], packingVersion: 0 }
const transport = { overseasLabels: { version: 0 }, domesticLabels: { version: 0 }, shipments: [] }
const detail = () => structuredClone({ customerView: { order, fees }, physical, transport, fees })
inputs.upload = async () => ({ fileId: '501' })
inputs.readWallet = async () => ({ actualBalance: ((1000 - spent) / 100).toFixed(2), availableBalance: ((1000 - spent - frozen) / 100).toFixed(2),
frozenBalance: (frozen / 100).toFixed(2), creditUsed: '0.00' })
context.request = async (method, path, body) => {
calls.push({ method, path, body: structuredClone(body) })
const reply = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req-test', traceId: 'trace-test' })
if (method === 'GET') {
if (path.startsWith('/api/merchant/')) return reply({ order: { ...order, ...(fault === 'customer final order' ? { orderNo: 'OTHER' } : {}) } })
const result = detail()
if (fault === 'aggregate customer') result.customerView.order.customerId = '999'
if (fault === 'aggregate order') result.customerView.order.orderNo = 'OTHER'
if (fault === 'aggregate version') result.customerView.order.version = null
return reply(result)
}
if (path === '/api/merchant/v1/last-leg/overseas-procurements') {
creations++
return reply({ order: { ...order, ...(creations === 2 && fault === 'create replay customer' ? { customerId: '999' } : {}) } })
}
assert.equal(method, 'POST')
const action = path.split('/CG-TEST-1/')[1]?.split('?')[0]
assert.ok(action, path)
if (seen.has(body.idempotencyKey)) {
assert.deepEqual(body, seen.get(body.idempotencyKey))
if (fault === 'replay deducts' && action === 'purchase') spent += 100
return reply(detail())
}
assert.equal(body.version, order.version)
seen.set(body.idempotencyKey, structuredClone(body))
switch (action) {
case 'review': order.status = 'APPROVED'; break
case 'purchase':
spent += 100
fees.push({ active: true, purpose: 'SAMPLE_PAYMENT', walletStatus: 'SETTLED', sourceLinked: true, walletBusinessNo: 'PAY-1' })
break
case 'overseas-receipts': case 'domestic-receipts':
physical.receipts.push({ id: String(601 + physical.receipts.length), leg: body.leg, active: true, goodQuantity: body.goodQuantity }); break
case 'overseas-receipts/complete': case 'domestic-receipts/complete': break
case 'packing':
physical.cartons = body.cartons.map((row, index) => ({ ...row, id: String(701 + index) })); physical.packingVersion++; break
case 'packing/confirm': assert.equal(body.packingVersion, physical.packingVersion); break
case 'labels': transport[body.leg === 'OVERSEAS' ? 'overseasLabels' : 'domesticLabels'].version++; break
case 'international-estimate':
frozen = 200; fees.push({ active: true, purpose: 'INTERNATIONAL_ESTIMATE', walletStatus: 'FROZEN' }); break
case 'labels/apply': break
case 'overseas-shipment': case 'customer-shipment':
transport.shipments.push({ leg: body.leg })
if (action === 'customer-shipment') order.status = 'SHIPPED_TO_CUSTOMER'
break
case 'charges/confirm':
spent += 150; frozen = 0
fees.find(row => row.purpose === 'INTERNATIONAL_ESTIMATE').walletStatus = 'RELEASED'
fees.push({ active: true, purpose: 'INTERNATIONAL_FREIGHT', walletStatus: 'SETTLED', sourceLinked: true, walletBusinessNo: 'PAY-2' })
break
default: assert.fail(`Unexpected operation ${action}`)
}
order.version++
return reply(detail())
}
return { context, inputs, calls, seen, physical, transport }
}
test('sample procurement traverses receipt, packing, both shipments and exact wallet settlement with same-key replays', async () => {
const h = completeFixture()
const proof = await verifyProcurementSample(h.context, h.inputs)
assert.equal(proof.status, 'PASSED')
assert.equal(proof.settledAmountCny, '2.50')
assert.deepEqual(proof.walletBusinessNos, ['PAY-1', 'PAY-2'])
assert.equal(h.physical.receipts.length, 4)
assert.equal(h.physical.cartons.length, 2)
assert.deepEqual(h.transport.shipments.map(row => row.leg), ['OVERSEAS', 'DOMESTIC'])
assert.equal(proof.walletChecks.length, 4)
assert.ok(proof.checks.every(check => check.passed))
})
for (const fault of ['aggregate customer', 'aggregate order', 'aggregate version', 'create replay customer', 'customer final order', 'replay deducts']) {
test(`procurement stops rather than passing ${fault}`, async () => {
const h = completeFixture(fault)
await assert.rejects(verifyProcurementSample(h.context, h.inputs))
assert.equal(h.context.createdResources.procurementSample.status, 'FAILED')
if (fault.startsWith('aggregate') || fault === 'create replay customer') assert.equal(h.seen.size, 0)
if (fault === 'replay deducts') assert.equal(h.physical.receipts.length, 0)
})
}
@@ -10,7 +10,7 @@ const cents = value => {
return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
}
/** Internal scenario; the TEST coordinator owns isolated customer creation, funding, sessions and file upload. */
/** Internal scenario; the coordinator supplies the configured customer, existing balance, session and upload. */
export async function verifyProcurementSample(context, inputs) {
if (!context.marker || typeof context.checkpoint !== 'function' || typeof context.request !== 'function'
|| inputs?.marker !== context.marker || !/^[1-9]\d*$/.test(String(inputs.customerId))
@@ -44,7 +44,13 @@ export async function verifyProcurementSample(context, inputs) {
await check(label, response?.http === 200 && String(response?.body?.code) === expectedCode)
return response.body.data
}
async function read() { detail = await request('GET', path(), undefined, '回查采购聚合详情'); return detail }
async function read() {
detail = await request('GET', path(), undefined, '回查采购聚合详情')
await check('采购聚合仍属于原客户原单且版本有效', detail?.customerView?.order?.orderNo === proof.orderNo
&& detail.customerView.order.customerId === customerId
&& Number.isSafeInteger(detail.customerView.order.version) && detail.customerView.order.version >= 0)
return detail
}
async function command(operation, values = {}, replay = false) {
const body = { version: detail.customerView.order.version, idempotencyKey: key(operation.replaceAll('/', '-')), reason: context.marker, ...values }
await request('POST', path(operation), body, operation)
@@ -76,7 +82,7 @@ export async function verifyProcurementSample(context, inputs) {
proof.orderNo = created?.order?.orderNo; await context.checkpoint()
await check('客户创建响应包含本轮订单', typeof proof.orderNo === 'string' && created.order.customerId === customerId)
const repeated = await request('POST', MERCHANT, create, '客户同键重放', inputs.customerToken)
await check('重放没有新建采购单', repeated.order.orderNo === proof.orderNo)
await check('重放没有新建采购单', repeated?.order?.orderNo === proof.orderNo && repeated.order.customerId === customerId)
await read()
await request('POST', path('review'), { version: detail.customerView.order.version, idempotencyKey: key('review'), decision: 'APPROVE', purchaseWarehouseId: String(inputs.warehouseId), remark: context.marker }, '审核通过并确定收货仓')
await read()
@@ -115,7 +121,9 @@ export async function verifyProcurementSample(context, inputs) {
await command('customer-shipment', { leg: 'DOMESTIC', labelVersion: domesticLabels, cartonIds, shippedAt: at() }, true)
await check('寄客户正常终态及单次交寄', detail.customerView.order.status === 'SHIPPED_TO_CUSTOMER' && detail.transport.shipments.filter(row => row.leg === 'DOMESTIC').length === 1)
const customer = await request('GET', `${MERCHANT}/${encodeURIComponent(proof.orderNo)}`, undefined, '客户最终详情', inputs.customerToken)
await check('客户可见终态且没有平台内部数据', customer.order.status === 'SHIPPED_TO_CUSTOMER' && !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
await check('客户可见终态且没有平台内部数据', customer?.order?.orderNo === proof.orderNo
&& customer.order.customerId === customerId && customer.order.status === 'SHIPPED_TO_CUSTOMER'
&& !['files','purchaseFacts','controls','internalRemark','operations'].some(field => Object.hasOwn(customer,field)))
await wallet('重复交寄不再次扣款', 250n, 0n)
proof.walletBusinessNos = [...new Set(detail.fees.filter(row => row.walletStatus === 'SETTLED').map(row => row.walletBusinessNo))]
await check('每笔成功消费保留权威钱包业务引用', proof.walletBusinessNos.length === 2 && proof.walletBusinessNos.every(value => typeof value === 'string' && value.length > 0))
@@ -1,26 +1,45 @@
import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true }
/** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */
export async function verifyUsTruckFulfillment(context, proof) {
export async function verifyUsTruckFulfillment(context, proof, { checkpoint = context.checkpoint } = {}) {
if (!proof || proof.marker !== context.marker || !proof.orderNo || !proof.customerId
|| context.createdResources?.phase4OverseasBusiness?.truck !== proof || proof.fulfillment
|| proof.finalStatus !== 'WAITING_INBOUND') throw Error('卡车履约必须使用本轮未执行的待入仓原单')
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw Error('卡车批次标识不合法')
const evidence = proof.fulfillment = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/us-truck-checkpoints/', import.meta.url)
// 总编排提供检查点时沿用其持久化边界;否则独占创建批次文件,拒绝覆盖旧执行。
const persist = checkpoint ? () => checkpoint.call(context) : async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
}
const base = `/api/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(proof.orderNo)}`
const suffix = `?customerId=${encodeURIComponent(proof.customerId)}`
const check = (name, ok) => {
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${proof.orderNo}`, OPTIONS)
if (!ok) throw Error(name)
}
const request = async (method, action, body) => {
const log = { method, action, status: 'SENDING' }
const request = async (method, action, body, expectedRejection = false) => {
const log = { method, action, body, expectedRejection, status: 'PREPARED' }
evidence.requests.push(log)
await persist()
log.status = 'SENDING'
const response = await context.request(method, `${base}${action}${suffix}`, body)
Object.assign(log, { http: response?.http, code: response?.body?.code,
requestId: response?.requestId, traceId: response?.traceId })
const ok = response?.http === 200 && String(response?.body?.code) === '200'
log.status = ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
await persist()
if (expectedRejection) {
check('卡车无效命令按预期错误码拒绝且保留诊断', [200, expectedRejection].includes(response?.http)
&& [String(expectedRejection), `A0${expectedRejection}`].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
return response.body.data
}
context.apiSuccess(MODULE, `本土卡车${method} ${action || '详情'}`, response,
`HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, OPTIONS)
if (!ok) throw Error(`卡车履约请求失败,不自动重发: ${action}`)
@@ -36,28 +55,51 @@ export async function verifyUsTruckFulfillment(context, proof) {
&& current.remark?.startsWith(context.marker) && Number(current.summary.receivedPalletCount) === 0)
const packages = Number(current.summary.packageCount)
check('卡车实收箱数来自原订单且为有效正整数', Number.isSafeInteger(packages) && packages > 0)
const packageCounts = packages >= 2 ? [1, packages - 1] : [packages]
const palletCount = packageCounts.length
const stages = [
['receive-pallets', 'WAITING_CONSOLIDATION'], ['allocate-pallets', 'ALLOCATED'],
['receive-pallets', 'WAITING_CONSOLIDATION'],
...(palletCount === 2 ? [['allocate-pallets', 'PARTIALLY_ALLOCATED', 0], ['allocate-pallets', 'ALLOCATED', 1]] : [['allocate-pallets', 'ALLOCATED', 0]]),
['prepare-dispatch', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'],
['start-transit', 'IN_TRANSIT'], ['complete', 'COMPLETED'],
]
let palletId, palletNo
for (const [action, state] of stages) {
let pallets
const allocatedIds = new Set()
for (const [action, state, allocationIndex] of stages) {
if (action === 'allocate-pallets' && allocationIndex === 1) {
await request('POST', '/prepare-dispatch', {
version: current.summary.version, idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker,
}, 409)
const unchanged = await request('GET', '')
check('卡车部分分配禁止待派车且原单完全不变', JSON.stringify(unchanged) === JSON.stringify(current))
}
const beforeVersion = current.summary.version
const body = { version: beforeVersion, idempotencyKey: `${context.marker}-truck-${action}`, remark: context.marker }
if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: 1, packageCount: packages, palletPackageCounts: [packages] })
if (action === 'allocate-pallets') body.palletIds = [palletId]
if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: palletCount, packageCount: packages, palletPackageCounts: packageCounts })
if (action === 'allocate-pallets') {
body.palletIds = [pallets[allocationIndex].id]
body.idempotencyKey += `-${allocationIndex}`
}
// 拒绝分支使用同一原单与独立键,回查整个聚合证明未偷偷收托或分配。
if (action === 'receive-pallets' || action === 'allocate-pallets' && allocationIndex === 0) {
const invalid = { ...body, idempotencyKey: `${body.idempotencyKey}-invalid`,
...(action === 'receive-pallets' ? { palletPackageCounts: packageCounts.map((n, i) => n + (i === 0 ? 1 : 0)) } : { palletIds: [body.palletIds[0], body.palletIds[0]] }) }
await request('POST', `/${action}`, invalid, 400)
const unchanged = await request('GET', '')
check(`卡车${action}拒绝后数量状态历史版本均不变`, JSON.stringify(unchanged) === JSON.stringify(current))
}
const next = await request('POST', `/${action}`, body)
identity(next)
check(`卡车${action}状态和版本正确`, next.summary.status === state && next.summary.version === beforeVersion + 1)
check('卡车原单实收托盘及箱数守恒', next.pallets?.length === 1 && Number(next.summary.receivedPalletCount) === 1
&& Number(next.pallets[0].packageCount) === packages && /^[1-9]\d*$/.test(String(next.pallets[0].id))
&& typeof next.pallets[0].palletNo === 'string' && next.pallets[0].palletNo.startsWith(`${proof.orderNo}-P`))
palletId ??= next.pallets[0].id
palletNo ??= next.pallets[0].palletNo
check('卡车履约复用原实收托盘', String(next.pallets[0].id) === String(palletId) && next.pallets[0].palletNo === palletNo
&& next.pallets[0].status === (action === 'receive-pallets' ? 'RECEIVED' : 'ALLOCATED')
&& Number(next.summary.allocatedPalletCount) === (action === 'receive-pallets' ? 0 : 1))
check('卡车原单实收托盘及箱数守恒', next.pallets?.length === palletCount && Number(next.summary.receivedPalletCount) === palletCount
&& new Set(next.pallets.map(row => String(row.id))).size === palletCount
&& next.pallets.every((row, index) => Number(row.packageCount) === packageCounts[index]
&& /^[1-9]\d*$/.test(String(row.id)) && typeof row.palletNo === 'string' && row.palletNo.startsWith(`${proof.orderNo}-P`)))
pallets ??= next.pallets.map(row => ({ id: String(row.id), palletNo: row.palletNo, packageCount: Number(row.packageCount) }))
if (action === 'allocate-pallets') allocatedIds.add(String(body.palletIds[0]))
check('卡车履约复用原实收托盘且只分配本次选择', next.pallets.every((row, index) => String(row.id) === pallets[index].id
&& row.palletNo === pallets[index].palletNo && row.status === (allocatedIds.has(String(row.id)) ? 'ALLOCATED' : 'RECEIVED'))
&& Number(next.summary.allocatedPalletCount) === allocatedIds.size)
check('卡车履约写入对应操作历史', next.histories?.some(row => row.idempotencyKey === body.idempotencyKey
&& row.toStatus === state && row.fromStatus === current.summary.status))
const replay = await request('POST', `/${action}`, body)
@@ -66,11 +108,13 @@ export async function verifyUsTruckFulfillment(context, proof) {
check('卡车履约状态已持久化且回查一致', JSON.stringify(current) === JSON.stringify(next))
}
Object.assign(proof, { finalStatus: 'COMPLETED', version: current.summary.version, retained: true })
Object.assign(evidence, { status: 'HTTP_VERIFIED', palletId, palletNo, packages })
Object.assign(evidence, { status: 'HTTP_VERIFIED', pallets, packages, partialAllocationVerified: palletCount === 2 })
await persist()
} catch (error) {
for (const log of evidence.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
evidence.status = 'FAILED'
evidence.failure = error.message
try { await persist() } catch (checkpointError) { evidence.checkpointFailure = checkpointError.message }
throw error
}
}
@@ -0,0 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { allocateWarehouseTransfer } from './warehouse-transfer-allocation.mjs'
// 用独立来源快照验证隔离、幂等与未知结果停写,不访问测试环境。
function fixture(fault = '') {
const proof = { marker: 'batch', customerId: '101', warehouseId: '201', transferNo: 'WT-1', inboundCode: 'IN-1', skuCode: 'SKU-1', status: 'APPROVED' }
const calls = [], snapshots = []
const allocation = { inventoryPositionId: '301', inboundOrderId: '401', inboundCode: 'IN-1', palletId: '501', palletNo: 'P-1', packageCount: 1 }
const allocated = { transferNo: 'WT-1', status: 'WAIT_RELABEL', relabelNo: 'RL-1', relabelApplicationId: '601', allocations: [allocation] }
const context = { marker: 'batch', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } },
assert() {}, apiSuccess() {},
async request(method, path, body, token) {
calls.push({ method, path, body: structuredClone(body), token })
let data
if (method === 'POST') {
if (fault === 'network') throw Error('connection closed')
if (fault === 'server') return { http: 503, body: { code: '503' }, requestId: 'request-1' }
data = structuredClone(allocated)
if (fault === 'wrong allocation') data.allocations[0].inboundOrderId = '999'
if (fault === 'replay' && calls.filter(c => c.method === 'POST').length === 2) data.relabelNo = 'RL-2'
} else if (path.includes('inventory-candidates')) {
data = [{ inventoryBalanceId: '701', inboundOrderId: '401', inboundCode: 'IN-1', overseasWarehouseId: '201', skuCode: 'SKU-1', positions: [{ positionId: '301', palletNo: 'P-1', availablePackages: 1 }] }]
if (fault === 'old stock') data[0].inboundCode = 'IN-OLD'
} else if (path.includes('/positions')) {
data = [{ id: '301', balanceId: '701', customerId: '101', overseasWarehouseId: '201', inboundOrderId: '401', inboundCode: 'IN-1', palletId: '501', palletNo: 'P-1', availablePackages: 1 }]
if (fault === 'foreign customer') data[0].customerId = '999'
if (fault === 'foreign inbound') data[0].inboundOrderId = '999'
if (fault === 'no stock') data[0].availablePackages = 0
} else if (path.startsWith('/api/merchant')) {
data = structuredClone(allocated)
if (fault === 'merchant drift') data.relabelApplicationId = '999'
} else data = { transferNo: 'WT-1', status: 'APPROVED', operatingWarehouseId: '201', needRelabel: true, packageCount: 1, allocations: [], sourceOrders: [{ inboundOrderId: '401', inboundCode: 'IN-1' }] }
return { http: 200, body: { code: '200', data } }
} }
const checkpoint = async () => { snapshots.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk unavailable') }
return { proof, context, calls, snapshots, options: { proof, merchantToken: 'secret-token', checkpoint } }
}
test('原转运分配同批库存、重放并由客户回查唯一换标申请', async () => {
const f = fixture()
await allocateWarehouseTransfer(f.context, f.options)
assert.equal(f.proof.status, 'WAIT_RELABEL')
assert.equal(f.proof.allocation.status, 'ALLOCATED_WAIT_RELABEL')
assert.equal(f.proof.sourcePositionId, '301')
const writes = f.calls.filter(c => c.method === 'POST')
assert.equal(writes.length, 2)
assert.deepEqual(writes[0].body, writes[1].body)
assert.equal(writes[0].body.allocations[0].packageCount, 1)
assert.equal(f.snapshots[0].allocation.requests[0].status, 'PREPARED')
assert.equal(JSON.stringify(f.snapshots).includes('secret-token'), false)
await assert.rejects(allocateWarehouseTransfer(f.context, f.options), /或已执行/)
assert.equal(f.calls.length, 6)
})
for (const fault of ['old stock', 'foreign customer', 'foreign inbound', 'no stock', 'network', 'server', 'wrong allocation', 'replay', 'merchant drift', 'disk']) {
test(`转运分配遇到 ${fault} 停止且保留证据`, async () => {
const f = fixture(fault)
await assert.rejects(allocateWarehouseTransfer(f.context, f.options))
assert.equal(f.proof.allocation.status, 'FAILED')
const writes = f.calls.filter(c => c.method === 'POST')
assert.equal(writes.length, ['replay', 'merchant drift'].includes(fault) ? 2 : ['network', 'server', 'wrong allocation'].includes(fault) ? 1 : 0)
if (['network', 'server'].includes(fault)) assert.equal(f.proof.allocation.requests.at(-1).status, 'UNKNOWN_RESULT')
if (fault === 'disk') assert.equal(f.calls.length, 0)
})
}
test('未登记的同形证明不得触发请求', async () => {
const f = fixture()
f.options.proof = structuredClone(f.proof)
await assert.rejects(allocateWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, 0)
})
@@ -0,0 +1,81 @@
import { mkdir, writeFile } from 'node:fs/promises'
const PLATFORM = '/api/platform/v1/last-leg'
const MODULE = 'phase4-overseas-client-services'
const id = value => String(value ?? '')
const validId = value => /^[1-9]\d*$/.test(id(value))
/** 已有库存转运按本轮原入仓单分配真实仓位,保留关联换标申请供后续使用。 */
export async function allocateWarehouseTransfer(context, { proof, merchantToken, checkpoint }) {
if (proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|| context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|| !proof.transferNo || !proof.inboundCode || !proof.skuCode || !validId(proof.warehouseId)
|| proof.status !== 'APPROVED' || !merchantToken || proof.allocation) throw Error('转运库存分配缺少本轮原单和客户来源,或已执行')
const evidence = proof.allocation = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/warehouse-transfer-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const check = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), `transferNo=${proof.transferNo}`, { scenario: 'warehouse-transfer-allocation', deploymentBlocker: true })
if (!passed) throw Error(name)
}
const request = async (method, path, body, token) => {
const log = { method, path, body, status: 'PREPARED' }; evidence.requests.push(log); await persist()
let response
try { log.status = 'SENDING'; response = await context.request(method, path, body, token) }
catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
const ok = response?.http === 200 && id(response?.body?.code) === '200'
Object.assign(log, { status: ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED',
http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
await persist()
context.apiSuccess(MODULE, '转运原库存分配', response, JSON.stringify(log), { scenario: 'warehouse-transfer-allocation', deploymentBlocker: true })
if (!ok) throw Error('转运库存请求失败,保留原单且不自动重发')
return response.body.data
}
const base = `${PLATFORM}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
try {
const transfer = await request('GET', base + scope)
check('转运原单已通过且待分配换标库存', transfer?.transferNo === proof.transferNo && transfer.status === 'APPROVED'
&& id(transfer.operatingWarehouseId) === proof.warehouseId && transfer.needRelabel === true
&& transfer.packageCount === 1 && transfer.allocations?.length === 0
&& transfer.sourceOrders?.some(row => row.inboundCode === proof.inboundCode))
const candidates = await request('GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?${new URLSearchParams({ operationWarehouseId: proof.warehouseId, inboundCode: proof.inboundCode })}`, undefined, merchantToken)
const eligible = (Array.isArray(candidates) ? candidates : []).filter(c => c.inboundCode === proof.inboundCode && c.skuCode === proof.skuCode
&& id(c.overseasWarehouseId) === proof.warehouseId && validId(c.inventoryBalanceId)
&& transfer.sourceOrders.some(s => id(s.inboundOrderId) === id(c.inboundOrderId)))
let source, position
for (const candidate of eligible) {
const positions = await request('GET', `${PLATFORM}/inventory/balances/${encodeURIComponent(candidate.inventoryBalanceId)}/positions${scope}`)
check('库存位置回查必须返回完整数组', Array.isArray(positions))
position = positions.find(p => id(p.customerId) === proof.customerId && id(p.overseasWarehouseId) === proof.warehouseId
&& p.inboundCode === proof.inboundCode && id(p.inboundOrderId) === id(candidate.inboundOrderId)
&& id(p.balanceId) === id(candidate.inventoryBalanceId)
&& validId(p.id) && validId(p.palletId) && Number(p.availablePackages) >= 1
&& candidate.positions?.some(cp => id(cp.positionId) === id(p.id) && cp.palletNo === p.palletNo && Number(cp.availablePackages) >= 1))
if (position) { source = candidate; break }
}
check('只使用本轮入仓单同SKU实际可用托盘仓位', Boolean(source && position))
Object.assign(proof, { sourceBalanceId: id(source.inventoryBalanceId), sourcePositionId: id(position.id), sourcePalletId: id(position.palletId),
sourcePalletNo: position.palletNo, sourceInboundOrderId: id(source.inboundOrderId) })
const body = evidence.request = { idempotencyKey: `${context.marker}-TRANSFER-ALLOCATE`, allocations: [{ inventoryPositionId: proof.sourcePositionId, palletId: proof.sourcePalletId, packageCount: 1 }] }
const allocated = await request('POST', `${base}/allocations${scope}`, body)
check('原库存分配进入待换标并产生唯一关联申请', allocated?.transferNo === proof.transferNo && allocated.status === 'WAIT_RELABEL'
&& typeof allocated.relabelNo === 'string' && allocated.relabelNo.length > 0 && validId(allocated.relabelApplicationId)
&& allocated.allocations?.length === 1 && id(allocated.allocations[0].inventoryPositionId) === proof.sourcePositionId
&& id(allocated.allocations[0].inboundOrderId) === proof.sourceInboundOrderId && allocated.allocations[0].inboundCode === proof.inboundCode
&& id(allocated.allocations[0].palletId) === proof.sourcePalletId && allocated.allocations[0].packageCount === 1)
const replay = await request('POST', `${base}/allocations${scope}`, body)
check('转运分配重放不生成第二申请或重复分配', JSON.stringify(replay) === JSON.stringify(allocated))
const merchant = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
check('客户回查同单同换标申请及真实来源', merchant?.transferNo === proof.transferNo && merchant.status === 'WAIT_RELABEL'
&& merchant.relabelNo === allocated.relabelNo && id(merchant.relabelApplicationId) === id(allocated.relabelApplicationId)
&& JSON.stringify(merchant.allocations) === JSON.stringify(allocated.allocations))
Object.assign(proof, { status: 'WAIT_RELABEL', relabelNo: allocated.relabelNo, relabelApplicationId: id(allocated.relabelApplicationId) })
evidence.status = 'ALLOCATED_WAIT_RELABEL'; await persist(); return proof
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
}
@@ -0,0 +1,67 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { outboundWarehouseTransfer } from './warehouse-transfer-outbound.mjs'
// 替身返回独立的单据与库存证据,验证错误流水不能被成功状态掩盖。
function fixture(fault = '') {
const allocation = { inventoryPositionId: '301', palletId: '401', palletNo: 'P-1', packageCount: 1, skuCode: 'NEW', inboundCode: 'IN-1', status: 'RESERVED' }
const proof = { marker: 'BATCH', customerId: '101', warehouseId: '201', transferNo: 'WT-1', relabelNo: 'RL-1', relabelApplicationId: '501', inboundCode: 'IN-1',
status: 'ALLOCATED', outboundAllocations: [allocation], relabelFulfillment: { execution: { status: 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND' } } }
const calls = [], snapshots = []
let shipped = false, writes = 0
const context = { marker: 'BATCH', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } }, assert() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (method === 'POST') {
writes++
if (fault === 'network') throw Error('connection lost')
shipped = true
}
if (path.includes('/ledger?')) {
const rows = shipped || fault === 'existing shipment' ? [{ id: '601', palletNo: fault === 'wrong pallet' ? 'P-OTHER' : 'P-1', customerId: fault === 'foreign customer' ? '999' : '101',
overseasWarehouseId: '201', skuCode: fault === 'old sku' ? 'OLD' : 'NEW', inboundCode: 'IN-1', businessType: 'SHIP', businessOrderNo: 'WT-1', deltaPackages: -1 }] : []
if (fault === 'double shipment' && shipped) rows.push({ ...rows[0], id: '602' })
if (fault === 'replay ledger' && writes === 2) rows[0].id = '699'
data = { total: rows.length, records: rows }
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'READY_FOR_OUTBOUND', fulfillmentStatus: fault === 'wrong relabel' ? 'COMPLETED' : 'OUTBOUNDED' } }
else data = { transferNo: 'WT-1', status: shipped ? 'OUTBOUNDED' : 'ALLOCATED', relabelNo: 'RL-1', relabelApplicationId: '501', operatingWarehouseId: '201',
packageCount: 1, fbaWarehouseCode: 'FBA-1', outboundAt: shipped ? '2026-10-22T12:00:00' : null,
allocations: [{ ...allocation, status: shipped ? 'SHIPPED' : 'RESERVED' }] }
return { http: 200, body: { code: '200', data } }
} }
const options = { proof, merchantToken: 'secret', checkpoint: async () => { snapshots.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk full') } }
return { context, options, proof, calls, snapshots }
}
test('原转运出库验证同单新SKU扣库及重放唯一性', async () => {
const f = fixture()
await outboundWarehouseTransfer(f.context, f.options)
assert.equal(f.proof.status, 'OUTBOUNDED')
assert.equal(f.proof.outbound.status, 'OUTBOUNDED_HTTP_VERIFIED')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 2)
assert.equal(JSON.stringify(f.snapshots).includes('secret'), false)
const count = f.calls.length
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, count)
})
for (const fault of ['network', 'existing shipment', 'foreign customer', 'wrong pallet', 'old sku', 'double shipment', 'replay ledger', 'wrong relabel', 'disk']) {
test(`原转运出库拒绝 ${fault}`, async () => {
const f = fixture(fault)
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
assert.equal(f.proof.outbound.status, 'FAILED')
assert.equal(f.proof.status, 'ALLOCATED')
const writes = f.calls.filter(c => c.method === 'POST').length
assert.equal(writes, ['disk', 'existing shipment'].includes(fault) ? 0 : ['replay ledger', 'wrong relabel'].includes(fault) ? 2 : 1)
if (fault === 'network') assert.equal(f.proof.outbound.requests.at(-1).status, 'UNKNOWN_RESULT')
})
}
test('原转运未完成换标不得发出请求', async () => {
const f = fixture()
f.proof.relabelFulfillment.execution.status = 'FAILED'
await assert.rejects(outboundWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, 0)
})
@@ -0,0 +1,83 @@
import { mkdir, writeFile } from 'node:fs/promises'
const BASE = '/api/platform/v1/last-leg'
const id = value => String(value ?? '')
const validId = value => /^[1-9]\d*$/.test(id(value))
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 原转运换标确认后出库,独立核验新SKU出库流水及重放不重复扣库。 */
export async function outboundWarehouseTransfer(context, { proof, merchantToken, checkpoint }) {
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|| proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|| proof.status !== 'ALLOCATED' || proof.relabelFulfillment?.execution?.status !== 'HTTP_VERIFIED_READY_FOR_TRANSFER_OUTBOUND'
|| proof.outboundAllocations?.length !== 1 || !merchantToken || proof.outbound) throw Error('转运出库缺少本轮原单换标完成证据,或已执行')
const allocation = proof.outboundAllocations[0]
if (!validId(allocation.inventoryPositionId) || !validId(allocation.palletId) || allocation.packageCount !== 1
|| allocation.status !== 'RESERVED' || allocation.inboundCode !== proof.inboundCode || !allocation.skuCode || !allocation.palletNo) throw Error('转运实际出库预留无效')
const evidence = proof.outbound = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/warehouse-transfer-outbound-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const check = (name, ok) => {
context.assert('phase4-overseas-client-services', name, null, Boolean(ok), `transferNo=${proof.transferNo}`,
{ scenario: 'warehouse-transfer-outbound', deploymentBlocker: true })
if (!ok) throw Error(name)
}
const request = async (method, path, body, token = context.token) => {
const entry = { method, path, body, status: 'SENDING' }; evidence.requests.push(entry); await persist()
let response
try { response = await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; throw error }
Object.assign(entry, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId,
status: !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'RESPONDED' })
await persist()
check(`转运出库 ${method} ${path}`, response?.http === 200 && id(response?.body?.code) === '200')
return response.body.data
}
const base = `${BASE}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
const same = (detail, status) => detail?.transferNo === proof.transferNo && detail.status === status
&& detail.relabelNo === proof.relabelNo && id(detail.relabelApplicationId) === proof.relabelApplicationId
&& id(detail.operatingWarehouseId) === proof.warehouseId && detail.packageCount === 1
&& detail.allocations?.length === 1 && id(detail.allocations[0].inventoryPositionId) === id(allocation.inventoryPositionId)
&& id(detail.allocations[0].palletId) === id(allocation.palletId) && detail.allocations[0].skuCode === allocation.skuCode
&& detail.allocations[0].packageCount === 1 && detail.allocations[0].inboundCode === proof.inboundCode
&& detail.allocations[0].status === (status === 'ALLOCATED' ? 'RESERVED' : 'SHIPPED')
const ledger = async () => {
const page = await request('GET', `${BASE}/inventory/ledger?${new URLSearchParams({ customerId: proof.customerId,
overseasWarehouseId: proof.warehouseId, businessOrderNo: proof.transferNo, businessType: 'SHIP', page: '1', size: '200' })}`)
check('转运出库流水完整且无重复记录', Array.isArray(page?.records) && Number(page.total) === page.records.length
&& new Set(page.records.map(row => id(row.id))).size === page.records.length)
return page.records
}
try {
const original = await request('GET', base + scope)
check('出库前仍为原转运实际新SKU预留', same(original, 'ALLOCATED') && Boolean(original.fbaWarehouseCode))
check('原转运出库前没有出库流水', (await ledger()).length === 0)
const body = { idempotencyKey: `${context.marker}-TRANSFER-OUTBOUND`, remark: context.marker }
const shipped = await request('POST', `${base}/outbound${scope}`, body)
check('原转运新SKU预留出库完成', same(shipped, 'OUTBOUNDED') && Boolean(shipped.outboundAt))
const movements = await ledger()
check('原转运唯一SHIP流水按实际新SKU托盘扣除一箱', movements.length === 1 && movements.every(row => validId(row.id)
&& row.businessType === 'SHIP' && row.businessOrderNo === proof.transferNo && row.inboundCode === proof.inboundCode
&& id(row.customerId) === proof.customerId && id(row.overseasWarehouseId) === proof.warehouseId
&& row.palletNo === allocation.palletNo
&& row.skuCode === allocation.skuCode && Number(row.deltaPackages) === -1))
const replay = await request('POST', `${base}/outbound${scope}`, body)
check('出库重放保持原单和流水不变', stable(replay) === stable(shipped) && stable(await ledger()) === stable(movements))
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
check('客户回查原转运出库事实一致', same(owned, 'OUTBOUNDED') && owned.outboundAt === shipped.outboundAt)
const relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
check('原关联换标同步出库且未提前完成', relabel?.application?.relabelNo === proof.relabelNo
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.fulfillmentStatus === 'OUTBOUNDED'
&& relabel.application.status === 'READY_FOR_OUTBOUND')
Object.assign(proof, { status: 'OUTBOUNDED' }); evidence.shipLedger = movements; evidence.status = 'OUTBOUNDED_HTTP_VERIFIED'
await persist(); return proof
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
}
@@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { receiveWarehouseTransfer } from './warehouse-transfer-receipt.mjs'
// 收货替身使用独立目标仓资源,覆盖资源不足、串单、重复入库和结算未完成。
function fixture(fault = '') {
const allocation = { inventoryPositionId: '301', palletId: '401', packageCount: 1, skuCode: 'NEW', inboundCode: 'IN-1' }
const proof = { marker: 'BATCH', customerId: '101', warehouseId: '201', transferNo: 'WT-1', relabelNo: 'RL-1', relabelApplicationId: '501', inboundCode: 'IN-1',
status: 'OUTBOUNDED', outboundAllocations: [allocation], outbound: { status: 'OUTBOUNDED_HTTP_VERIFIED' } }
const pallet = { id: '601', warehouseId: '202', customerId: '0', locationId: '701', palletCode: 'TARGET', status: 'IDLE', fbaWarehouseCode: 'FBA-1' }
const calls = [], saved = []
let received = false, writes = 0
const context = { marker: 'BATCH', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { transferFulfillment: proof } }, assert() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (method === 'POST') {
writes++
if (fault === 'network') throw Error('connection lost')
assert.equal(body.destinationOverseasWarehouseId, '202')
assert.equal(body.destinationPalletId, '601')
received = true
}
if (path.includes('/pallets?')) data = { total: fault === 'no resource' ? 0 : 1, records: fault === 'no resource' ? [] : [{ ...pallet }] }
else if (path.endsWith('/pallets/601')) data = { ...pallet, status: fault === 'occupied' ? 'OCCUPIED' : 'IDLE' }
else if (path.endsWith('/locations/701')) data = { id: '701', warehouseId: '202', customerId: '0', locationCode: 'A0', locationType: 'VIRTUAL', status: fault === 'location blocked' ? 'OCCUPIED' : 'ACTIVE' }
else if (path.includes('/warehouse-admin/')) data = { warehouse: { id: '202', tenantId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE', warehouseCode: 'WH2', warehouseName: 'Target' } }
else if (path.includes('/ledger?')) {
const rows = received ? [{ id: '801', customerId: fault === 'foreign customer' ? '999' : '101', overseasWarehouseId: '202', businessOrderNo: 'WT-1',
businessType: 'STOCK_IN', inboundCode: 'IN-1', skuCode: 'NEW', palletNo: 'TARGET', warehouseLocationCode: 'A0', deltaPackages: 1 }] : []
if (fault === 'double receipt' && received) rows.push({ ...rows[0], id: '802' })
if (fault === 'replay drift' && writes === 2) rows[0].id = '899'
data = { total: rows.length, records: rows }
} else if (path.includes('/relabel-applications/')) data = { application: { relabelNo: 'RL-1', customerOrderNo: 'WT-1', status: 'COMPLETED', fulfillmentStatus: 'COMPLETED', walletStatus: fault === 'unsettled' ? 'SETTLE_PENDING' : 'SETTLED' } }
else data = { transferNo: 'WT-1', status: received ? 'RECEIVED' : 'OUTBOUNDED', relabelNo: 'RL-1', relabelApplicationId: '501', fbaWarehouseCode: 'FBA-1',
receiptCommands: received ? [{ status: 'COMPLETED', sourceInventoryPositionId: '301', request: { idempotencyKey: 'BATCH-TRANSFER-RECEIVE', batchNo: 'WT-1' },
target: { warehouseId: '202', palletId: fault === 'target drift' ? '999' : '601', palletNo: 'TARGET', locationId: '701', locationCode: 'A0', fbaCode: 'FBA-1' } }] : [],
allocations: [{ ...allocation, status: received ? 'RECEIVED' : 'SHIPPED', destinationInventoryPositionId: received ? '901' : null }] }
return { http: 200, body: { code: '200', data } }
} }
const options = { proof, merchantToken: 'secret', delay: async () => {}, checkpoint: async () => { saved.push(structuredClone(proof)); if (fault === 'disk') throw Error('disk full') } }
return { context, options, proof, calls, saved }
}
test('原转运复用异仓托盘收货并核对唯一入库和结算终态', async () => {
const f = fixture()
await receiveWarehouseTransfer(f.context, f.options)
assert.equal(f.proof.status, 'RECEIVED')
assert.equal(f.proof.receipt.status, 'RECEIVED_AND_SETTLED_HTTP_VERIFIED')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 2)
assert.ok(f.calls.filter(c => c.method === 'POST').every(c => c.path.includes('/receipts?')))
assert.equal(JSON.stringify(f.saved).includes('secret'), false)
const count = f.calls.length
await assert.rejects(receiveWarehouseTransfer(f.context, f.options))
assert.equal(f.calls.length, count)
})
for (const fault of ['no resource', 'occupied', 'location blocked', 'network', 'target drift', 'foreign customer', 'double receipt', 'replay drift', 'unsettled', 'disk']) {
test(`转运收货遇到 ${fault} 明确失败且不补造资源`, async () => {
const f = fixture(fault)
await assert.rejects(receiveWarehouseTransfer(f.context, f.options))
assert.equal(f.proof.receipt.status, 'FAILED')
const writes = f.calls.filter(c => c.method === 'POST').length
assert.equal(writes, ['no resource', 'occupied', 'location blocked', 'disk'].includes(fault) ? 0 : ['replay drift', 'unsettled'].includes(fault) ? 2 : 1)
if (fault === 'network') assert.equal(f.proof.receipt.requests.at(-1).status, 'UNKNOWN_RESULT')
})
}
@@ -0,0 +1,125 @@
import { mkdir, writeFile } from 'node:fs/promises'
const BASE = '/api/platform/v1/last-leg'
const RESOURCES = '/api/platform/v1/overseas-warehouse-resources'
const id = value => String(value ?? '')
const validId = value => /^[1-9]\d*$/.test(id(value))
const stable = value => JSON.stringify(value, (_, item) => item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item)
/** 转运收货复用其他海外仓的空闲匹配托盘,并验证原单入库及关联换标结算。 */
export async function receiveWarehouseTransfer(context, { proof, merchantToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
if (context.createdResources?.phase4OverseasClientServices?.transferFulfillment !== proof
|| proof?.marker !== context.marker || !/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)
|| proof.customerId !== id(context.context?.merchantQualificationFixture?.tenantId)
|| proof.status !== 'OUTBOUNDED' || proof.outbound?.status !== 'OUTBOUNDED_HTTP_VERIFIED'
|| proof.outboundAllocations?.length !== 1 || !merchantToken || proof.receipt) throw Error('转运收货缺少本轮已出库原单,或已执行')
const allocation = proof.outboundAllocations[0]
const evidence = proof.receipt = { status: 'RUNNING', requests: [] }
let initialized = false
const directory = new URL('../../reports/warehouse-transfer-receipt-checkpoints/', import.meta.url)
const persist = checkpoint || (async () => {
await mkdir(directory, { recursive: true })
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2), { encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
initialized = true
})
const check = (name, ok) => {
context.assert('phase4-overseas-client-services', name, null, Boolean(ok), `transferNo=${proof.transferNo}`,
{ scenario: 'warehouse-transfer-receipt', deploymentBlocker: true })
if (!ok) throw Error(name)
}
const request = async (method, path, body, token = context.token) => {
const entry = { method, path, body, status: 'SENDING' }; evidence.requests.push(entry); await persist()
let response
try { response = await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; throw error }
Object.assign(entry, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId,
status: !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'RESPONDED' }); await persist()
check(`转运收货 ${method} ${path}`, response?.http === 200 && id(response?.body?.code) === '200')
return response.body.data
}
const base = `${BASE}/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`
const scope = `?customerId=${encodeURIComponent(proof.customerId)}`
const same = (detail, status) => detail?.transferNo === proof.transferNo && detail.status === status
&& detail.relabelNo === proof.relabelNo && id(detail.relabelApplicationId) === proof.relabelApplicationId
&& detail.allocations?.length === 1 && id(detail.allocations[0].inventoryPositionId) === id(allocation.inventoryPositionId)
&& detail.allocations[0].packageCount === 1 && detail.allocations[0].skuCode === allocation.skuCode
&& detail.allocations[0].status === (status === 'OUTBOUNDED' ? 'SHIPPED' : 'RECEIVED')
try {
const original = await request('GET', base + scope)
check('原转运出库来源不变', same(original, 'OUTBOUNDED') && Boolean(original.fbaWarehouseCode))
let target
// 完整翻页寻找既有空闲资源,不创建公共托盘,也不借用已被其他业务占用的托盘。
for (let page = 1; page <= 100 && !target; page++) {
const pallets = await request('GET', `${RESOURCES}/pallets?status=IDLE&page=${page}&size=200`)
check('目标托盘分页有效', Array.isArray(pallets?.records) && Number.isSafeInteger(Number(pallets.total)) && Number(pallets.total) >= 0)
for (const candidate of pallets.records) {
if (candidate.status !== 'IDLE' || id(candidate.customerId) !== '0' || !validId(candidate.warehouseId)
|| id(candidate.warehouseId) === proof.warehouseId || candidate.fbaWarehouseCode !== original.fbaWarehouseCode
|| !validId(candidate.id) || !validId(candidate.locationId)) continue
const resource = `${RESOURCES}/${candidate.warehouseId}`
const pallet = await request('GET', `${resource}/pallets/${candidate.id}`)
if (pallet?.status !== 'IDLE' || id(pallet.id) !== id(candidate.id) || id(pallet.customerId) !== '0'
|| id(pallet.warehouseId) !== id(candidate.warehouseId) || id(pallet.locationId) !== id(candidate.locationId)
|| pallet.fbaWarehouseCode !== original.fbaWarehouseCode || !pallet.palletCode) continue
const location = await request('GET', `${resource}/locations/${pallet.locationId}`)
if (id(location?.customerId) !== '0' || id(location?.warehouseId) !== id(pallet.warehouseId)
|| id(location?.id) !== id(pallet.locationId) || !location.locationCode
|| !(location.locationType === 'VIRTUAL' ? location.status === 'ACTIVE' : location.status === 'IDLE')) continue
const master = await request('GET', `/api/platform/v1/warehouse-admin/${pallet.warehouseId}?tenantId=0`)
const warehouse = master?.warehouse
if (id(warehouse?.id) !== id(pallet.warehouseId) || id(warehouse.tenantId) !== '0'
|| warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') continue
target = { pallet, location, warehouse }; break
}
if (page * 200 >= Number(pallets.total)) break
}
check('必须存在其他海外仓同目的仓的空闲托盘与可预留库位', Boolean(target))
evidence.target = target
const { pallet, location, warehouse } = target
const ledger = async () => {
const data = await request('GET', `${BASE}/inventory/ledger?${new URLSearchParams({ customerId: proof.customerId,
overseasWarehouseId: id(warehouse.id), businessOrderNo: proof.transferNo, businessType: 'STOCK_IN', page: '1', size: '200' })}`)
check('目标收货流水完整无重复', Array.isArray(data?.records) && Number(data.total) === data.records.length
&& new Set(data.records.map(row => id(row.id))).size === data.records.length)
return data.records
}
check('目标仓未重复接收原转运单', (await ledger()).length === 0)
const body = evidence.command = { sourceInventoryPositionId: id(allocation.inventoryPositionId),
destinationOverseasWarehouseId: id(warehouse.id), destinationOverseasWarehouseCode: warehouse.warehouseCode,
destinationOverseasWarehouseName: warehouse.warehouseName, destinationWarehouseCode: original.fbaWarehouseCode,
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
warehouseLocationId: id(location.id), warehouseLocationCode: location.locationCode,
batchNo: proof.transferNo, idempotencyKey: `${context.marker}-TRANSFER-RECEIVE`, remark: context.marker }
const received = await request('POST', `${base}/receipts${scope}`, body)
check('原转运收货完成且生成目标库存位置', same(received, 'RECEIVED') && validId(received.allocations[0].destinationInventoryPositionId))
const committed = received.receiptCommands?.[0]
check('收货命令完成且权威目标绑定保持一致', received.receiptCommands?.length === 1 && committed?.status === 'COMPLETED'
&& id(committed.sourceInventoryPositionId) === id(allocation.inventoryPositionId)
&& committed.request?.idempotencyKey === body.idempotencyKey && committed.request?.batchNo === body.batchNo
&& id(committed.target?.warehouseId) === id(warehouse.id) && id(committed.target?.palletId) === id(pallet.id)
&& committed.target?.palletNo === pallet.palletCode && id(committed.target?.locationId) === id(location.id)
&& committed.target?.locationCode === location.locationCode && committed.target?.fbaCode === original.fbaWarehouseCode)
const movements = await ledger()
check('目标仓唯一原单新SKU入库一箱', movements.length === 1 && movements.every(row => validId(row.id)
&& id(row.customerId) === proof.customerId && id(row.overseasWarehouseId) === id(warehouse.id)
&& row.businessOrderNo === proof.transferNo && row.businessType === 'STOCK_IN' && row.inboundCode === proof.inboundCode
&& row.skuCode === allocation.skuCode && row.palletNo === pallet.palletCode && row.warehouseLocationCode === location.locationCode
&& Number(row.deltaPackages) === 1))
const replay = await request('POST', `${base}/receipts${scope}`, body)
check('收货重放不重复入库', stable(replay) === stable(received) && stable(await ledger()) === stable(movements))
const owned = await request('GET', `/api/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.transferNo)}`, undefined, merchantToken)
check('客户原转运收货事实一致', same(owned, 'RECEIVED') && stable(owned.allocations) === stable(received.allocations))
let relabel
for (let attempt = 0; attempt < 31; attempt++) {
relabel = await request('GET', `${BASE}/relabel-applications/${encodeURIComponent(proof.relabelNo)}`)
check('原换标随转运收货完成且身份保持', relabel?.application?.relabelNo === proof.relabelNo
&& relabel.application.customerOrderNo === proof.transferNo && relabel.application.status === 'COMPLETED'
&& relabel.application.fulfillmentStatus === 'COMPLETED')
if (relabel.application.walletStatus === 'SETTLED') break
if (attempt < 30) await delay()
}
check('原转运关联换标费用完成结算', relabel.application.walletStatus === 'SETTLED')
evidence.stockInLedger = movements; evidence.status = 'RECEIVED_AND_SETTLED_HTTP_VERIFIED'; proof.status = 'RECEIVED'
await persist(); return proof
} catch (error) { evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error }
}