test: extend eight-business acceptance flows and offline contracts
This commit is contained in:
@@ -12,20 +12,20 @@ export function describeHomepageBusinessCoverage() {
|
||||
additionalModules: ['phase5-root-fulfillment', 'dropship-pallet-matrix'],
|
||||
covered: '默认头程至海外履约主链;拆分根单、一件代发矩阵为可选专项',
|
||||
remaining: '各订单类型、税制及根/余量分支未由默认链全部执行' },
|
||||
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt'],
|
||||
additionalModules: ['phase4-overseas-return-followups'], covered: '退货预报、审核、签收、合箱及边界',
|
||||
{ title: '退货换标', route: '/overseas-warehouse/return-relabel/create', modules: ['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
|
||||
covered: '退货预报、审核、签收、合箱、换标、客户确认及换标后出库',
|
||||
remaining: '换标、客户确认及换标后出库已接入默认后续模块;固定客户跨客户边界仍缺资源,真实HTTP待验' },
|
||||
{ title: '美国本土卡', route: '/cross-border/domestic-trucking/create', modules: ['phase4-overseas-business'],
|
||||
covered: '创建、编辑、审核及原单实收、分配、派车、运输完成,逐步幂等重放', remaining: '独立本土卡车正常链已接入;超托审核、部分分配及真实HTTP待验,不能替代车队签收证据' },
|
||||
covered: '创建、编辑、审核及原单实收、两托部分至全部分配、派车、运输完成,逐步幂等重放', remaining: '独立本土卡车正常及部分分配链已接入;超托审核与真实HTTP待验,不能替代车队签收证据' },
|
||||
{ title: '美本土散板', route: '/cross-border/pallet-shipping/create', modules: ['phase4-overseas-business', 'phase5-overseas-fulfillment'],
|
||||
covered: '原单创建、需求池接车、PDA实收、原车加货及共享装车签收,核对原单终态与数量', remaining: '正常链已接入默认编排;异常履约、完整费用边界及真实HTTP待验' },
|
||||
{ title: '海外采购', route: '/cross-border/procurement/create', modules: ['phase4-overseas-business'],
|
||||
additionalModules: ['phase4-overseas-wallet-and-pallet-cost'], covered: '创建、备注编辑、审核驳回;固定客户正常样品采购至寄客户及资金核对已接入默认链',
|
||||
{ title: '海外采购', route: '/cross-border/procurement/create', modules: ['phase4-overseas-business', 'phase4-overseas-wallet-and-pallet-cost'],
|
||||
covered: '创建、备注编辑、审核驳回;固定客户正常样品采购至寄客户及资金核对已接入默认链',
|
||||
remaining: '固定客户已接入正常样品采购、收货、核费及寄客户;异常、退款及其他采购类型未全覆盖,真实HTTP待验' },
|
||||
{ title: '贴标申请', route: '/overseas-warehouse/labeling/create', modules: ['phase4-overseas-client-services'],
|
||||
covered: '客户原申请创建、审核、PDA箱标/SKU执行、换标组托、完工及客户确认', remaining: '原申请已达待排车;同单排车至交付、异常边界与真实HTTP仍待补验' },
|
||||
{ title: '贴标申请', route: '/overseas-warehouse/labeling/create', modules: ['phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'],
|
||||
covered: '客户原申请审核、PDA换标、客户确认、同单新箱唛出库签收、车次释放及原费用结算回查', remaining: '异常边界与真实HTTP仍待验' },
|
||||
{ title: '仓储转运', route: '/overseas-warehouse/storage-transfer/create', modules: ['phase4-overseas-client-services'],
|
||||
covered: '客户申请、提交、审核与拒绝、取消边界', remaining: '该转运审核通过后的入库计量、排车、出库及交付未接入默认闭环' },
|
||||
covered: '客户申请、提交、审核与拒绝、取消边界;已有库存原单分配、关联换标、出库、异仓收货及唯一库存流水与结算终态核验', remaining: '在途转运入库计量、多来源及部分收货异常矩阵仍待补;真实HTTP待验' },
|
||||
{ title: '货物废弃', route: '/overseas-warehouse/cargo-disposal/create', modules: ['phase4-overseas-client-services'],
|
||||
covered: '申请、拒绝释放、批准锁箱、PDA照片与实际扣库、客户确认及终态重放', remaining: '已接入同一申请公共库存总账核对;独立箱/SKU流水证据、多来源边界及真实HTTP仍待验' },
|
||||
].map(business => ({ ...business,
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { summarizeReport, describeHomepageBusinessCoverage } from './acceptance-overview.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
|
||||
import { describeAcceptanceScope, normalizeModuleOrder } from './admin-test-env-acceptance.mjs'
|
||||
|
||||
test('业务覆盖清单逐项对应客户端首页八个入口,未闭环不可标记通过', () => {
|
||||
const homepage = readFileSync(new URL('../../apps/client-web/src/modules/dashboard/views/DashboardView.vue', import.meta.url), 'utf8')
|
||||
@@ -15,12 +15,31 @@ test('业务覆盖清单逐项对应客户端首页八个入口,未闭环不
|
||||
const registered = new Set([...scope.defaultModules, ...scope.optionalModules])
|
||||
for (const business of businesses) {
|
||||
assert.ok(business.modules.every(name => registered.has(name)))
|
||||
assert.deepEqual(business.defaultModules, business.modules, `${business.title}正常链必须默认执行`)
|
||||
assert.ok(business.modules.every(name => !scope.fixedResourceSkippedModules.includes(name)), `${business.title}不能因固定客户跳过`)
|
||||
assert.ok(business.additionalModules.every(({ name }) => registered.has(name)))
|
||||
assert.ok(business.remaining)
|
||||
assert.equal(business.currentVersionVerified, false)
|
||||
assert.equal(business.endToEndCoverage, 'INCOMPLETE')
|
||||
}
|
||||
assert.equal(businesses.find(item => item.title === '海外采购').additionalModules[0].skippedWithFixedResources, false)
|
||||
assert.ok(businesses.find(item => item.title === '海外采购').modules.includes('phase4-overseas-wallet-and-pallet-cost'))
|
||||
assert.ok(businesses.find(item => item.title === '退货换标').modules.includes('phase4-overseas-return-followups'))
|
||||
})
|
||||
|
||||
test('八大业务定向执行保留正常履约的直接前置顺序', () => {
|
||||
const dependencies = [
|
||||
['phase4-overseas-return-receipt', 'phase4-overseas-return-followups'],
|
||||
['phase4-overseas-business', 'phase4-overseas-wallet-and-pallet-cost'],
|
||||
['phase4-overseas-client-services', 'phase4-overseas-dispatch'],
|
||||
['phase4-overseas-business', 'phase5-overseas-fulfillment'],
|
||||
['phase4-overseas-dispatch', 'phase5-overseas-fulfillment'],
|
||||
]
|
||||
for (const [producer, consumer] of dependencies) {
|
||||
const planned = normalizeModuleOrder([consumer])
|
||||
assert.ok(planned.includes(producer), `${consumer}缺少${producer}`)
|
||||
assert.ok(planned.indexOf(producer) < planned.indexOf(consumer))
|
||||
assert.equal(planned.filter(name => name === producer).length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('注册表返回独立副本,默认与可选不重复', () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ const MODULE_DEPENDENCIES = new Map([
|
||||
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
|
||||
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']],
|
||||
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations', 'phase4-overseas-client-services']],
|
||||
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch', 'phase4-overseas-business']],
|
||||
['phase5-terminal-drayage', ['phase4-overseas-pda']],
|
||||
['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']],
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -46,7 +46,9 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'],
|
||||
let output = ''; let errors = ''
|
||||
child.stdout.on('data', chunk => { output += chunk })
|
||||
child.stderr.on('data', chunk => { errors += chunk })
|
||||
const ready = await new Promise((resolve, reject) => {
|
||||
// Python 启动失败时也必须关闭本地消费者,否则失败用例会占住整个测试进程。
|
||||
let ready
|
||||
try { ready = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { child.kill(); reject(new Error('Proxy readiness timeout')) }, 8000)
|
||||
const onData = () => {
|
||||
const line = output.split('\n').find(item => item.trim())
|
||||
@@ -57,7 +59,14 @@ async function fixture(selector = ['--loading-plan-id', '2100000000000000001'],
|
||||
child.stdout.on('data', onData)
|
||||
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', code => { if (!output) { clearTimeout(timer); reject(new Error(`Proxy exit ${code}`)) } })
|
||||
})
|
||||
}) } catch (error) {
|
||||
if (child.exitCode === null && !child.killed) child.kill()
|
||||
await close(consumer)
|
||||
assert.equal(dirname(resolve(directory)), resolve(tmpdir()))
|
||||
assert.ok(basename(directory).startsWith('event-fault-offline-'))
|
||||
await rm(directory, { recursive: true })
|
||||
throw error
|
||||
}
|
||||
const dataUrl = `http://127.0.0.1:${ready.dataPort}${endpoint}`
|
||||
const controlUrl = `http://127.0.0.1:${ready.controlPort}`
|
||||
return { calls, applied, log, invalidAck: value => { invalidAck = value },
|
||||
|
||||
@@ -15,6 +15,9 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({
|
||||
})
|
||||
|
||||
export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/OrderPreDepartureClient.java',
|
||||
'lastleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ',
|
||||
'尾程列表按客户及订单筛选分页读取Order未开船订单,内部令牌保护的GET只读查询,不写订单或库存'),
|
||||
feign('firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java',
|
||||
'firstleg-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ',
|
||||
'直送路线守卫经内部令牌读取原预约路线事实,GET无远端业务写入,不接管预约创建'),
|
||||
|
||||
@@ -36,6 +36,7 @@ const expectedOnlineEdges = [
|
||||
'lastleg-service->finance-service',
|
||||
'lastleg-service->firstleg-service',
|
||||
'lastleg-service->iam-service',
|
||||
'lastleg-service->order-service',
|
||||
'lastleg-service->print-service',
|
||||
'lastleg-service->warehouse-overseas-service',
|
||||
'lastleg-service->warehouse-service',
|
||||
@@ -108,7 +109,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata',
|
||||
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
|
||||
.map(({ path, source, target }) => ({ path, source, target }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
assert.equal(reviewed.length, 81)
|
||||
assert.equal(reviewed.length, 82)
|
||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||
})
|
||||
|
||||
@@ -287,10 +288,14 @@ test('known user entries expose every longest simple REQUEST path over the two-h
|
||||
'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'firstleg-service': { hops: 6, paths: [
|
||||
'firstleg-service->lastleg-service->order-service->finance-service->file-service->iam-service->auth-service',
|
||||
'firstleg-service->lastleg-service->order-service->finance-service->file-service->iam-service->warehouse-service',
|
||||
'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->auth-service',
|
||||
'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'finance-service': { hops: 6, paths: [
|
||||
'finance-service->firstleg-service->lastleg-service->order-service->file-service->iam-service->auth-service',
|
||||
'finance-service->firstleg-service->lastleg-service->order-service->file-service->iam-service->warehouse-service',
|
||||
'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->auth-service',
|
||||
'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
@@ -302,7 +307,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h
|
||||
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->auth-service',
|
||||
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service',
|
||||
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->auth-service',
|
||||
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service'
|
||||
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service',
|
||||
'lastleg-service->order-service->finance-service->firstleg-service->file-service->iam-service->auth-service',
|
||||
'lastleg-service->order-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'warehouse-service': { hops: 0, paths: ['warehouse-service'] },
|
||||
'warehouse-overseas-service': { hops: 1, paths: [
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"numberSequences": "KEEP"
|
||||
},
|
||||
"sourceSha256": {
|
||||
"deploy/sql/头程数据库统一增量-MySQL8.sql": "38013327896214ccc4fe317f507bfcf0b11377b07a66cd4ffa41b109b73558fd",
|
||||
"deploy/sql/头程数据库统一增量-MySQL8.sql": "20f8f8129dad86957872d68fdea81967b72e5bdc7f1adef612677cc59e091bf4",
|
||||
"deploy/sql/海外区域统一增量-MySQL8.sql": "db307236f5d0275c214db49888fe0d16d8a954802eb50ad5de52ed33ccfab4a7"
|
||||
},
|
||||
"pendingReview": [
|
||||
|
||||
@@ -133,7 +133,7 @@ test('invalid unload proof still restores and incomplete restoration never becom
|
||||
}
|
||||
})
|
||||
test('Python reverse SQL routing, scope isolation and full configuration recovery fail closed offline', () => {
|
||||
const result = spawnSync('python', ['-c', String.raw`
|
||||
const result = spawnSync(process.env.PYTHON || 'python', ['-c', String.raw`
|
||||
import ast, copy, importlib.util, io, pathlib, sys
|
||||
from unittest.mock import patch
|
||||
sys.dont_write_bytecode = True
|
||||
@@ -310,7 +310,7 @@ test('F40 hook faults only first signature, captures whole inventory and always
|
||||
assert.equal(context.context.f40RootFulfillmentProof.deliveryEventRecovery.restored,true)
|
||||
})
|
||||
test('Python DELIVERY SQL uses actual payload, original Lastleg retry, and Order database without inventory queries',()=>{
|
||||
const result=spawnSync('python',['-c',String.raw`
|
||||
const result=spawnSync(process.env.PYTHON || 'python',['-c',String.raw`
|
||||
import importlib.util,sys,ast,pathlib,copy
|
||||
from unittest.mock import patch,MagicMock,mock_open
|
||||
sys.dont_write_bytecode=True
|
||||
|
||||
@@ -134,7 +134,7 @@ test('NON_NULL source hashes agree across runtime and proof and reject explicit
|
||||
invalid.producer.envelope.payloadHash=invalid.producer.payloadHash
|
||||
invalid.producer.envelopeJson=JSON.stringify(invalid.producer.envelope)
|
||||
assert.throws(()=>assertTestFeeVoidFaultProof(invalid,scope),/SOURCE_HASH/)
|
||||
const result=spawnSync('python',['-c',String.raw`
|
||||
const result=spawnSync(process.env.PYTHON || 'python',['-c',String.raw`
|
||||
import sys,importlib.util,json,copy,hashlib
|
||||
from unittest.mock import patch
|
||||
sys.dont_write_bytecode=True
|
||||
@@ -160,7 +160,7 @@ with patch.object(m,'inspect'),patch.object(m,'read_sql',side_effect=lambda *arg
|
||||
})
|
||||
|
||||
test('Python FEE_VOID routing, exact decimal hashes, preflight gating and restore reentry are offline',()=>{
|
||||
const result=spawnSync('python',['-c',String.raw`
|
||||
const result=spawnSync(process.env.PYTHON || 'python',['-c',String.raw`
|
||||
import sys,importlib.util,pathlib,copy,decimal,ast,io
|
||||
from unittest.mock import patch,mock_open
|
||||
sys.dont_write_bytecode=True
|
||||
|
||||
Reference in New Issue
Block a user