This commit is contained in:
2026-10-22 14:37:59 +08:00
parent de96e47d74
commit 760bf8daa1
49 changed files with 2450 additions and 117 deletions
+25 -3
View File
@@ -1,5 +1,27 @@
# Admin 测试环境 HTTP 模块
`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`。
`looseboard-pool-receipt.mjs` 衔接散板原单的已确认 booking,登记唯一来货,按调用方明确提供的逐托测量经 PDA 草稿、生成、绑定、合成测试照片和完成打托,最后运营确认实收并核验重放不变。检查同客户、同仓、同目的地、原托身份及逐托箱数;请求前后写总入口 checkpoint,未知结果停止,不创建客户或主档,不取消现场。此助手仅达到 `RECEIVED_READY_FOR_DISPATCH`,默认 looseboard-fulfillment.mjs 已衔接独立入库流水核验、原车加货及后续共享履约。离线合同为 `looseboard-pool-receipt-contract.test.mjs`,不代表真实 HTTP 通过。
`looseboard-pool-preparation.mjs` 是 `looseboard-pool-binding.mjs` 的车次前置编排:仅接受本轮已核验、同仓同车同司机、原排车尚未扫码发运的车次;已有有效容量计划直接复用,缺计划才依据本轮真实实托生成明确标记为非实测的受控预计计划。随后创建/发布余位,分别同键重放核验,调用原散板单绑定助手。过程经总入口 checkpoint 保存原请求和诊断,未知结果停止,不创建车辆、司机或客户。离线合同为 `looseboard-pool-preparation-contract.test.mjs`。此编排由默认 looseboard-fulfillment.mjs 调用;脚本接入不代表真实 HTTP 已通过。
`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`。
`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通过。
`cargo-discard-fulfillment.mjs` 为默认 `phase4-overseas-client-services.mjs` 的内部后续:仅衔接本批已批准的单箱一件代发废弃申请,复用同仓 PDA,扫码解析、上传合成测试图片、确认实际废弃、核对公共余额减一箱及唯一 DISCARD 总账,再由客户确认完成,重放两端完成命令核对库存、历史和时间不变。每次请求前保存 `reports/cargo-discard-checkpoints/<marker>.json`,未知结果停写,不自动重跑或清理;不创建客户或公共资源。多来源/多箱边界及独立箱、SKU、箱级流水底层取证仍保留缺口,不能用公共总账替代底层证据。真实 HTTP 以后执行;离线合同只验证编排与断言。
固定客户模式下 `phase4-overseas-wallet-and-pallet-cost.mjs` 不再整模块跳过:默认链调用既有 `procurement-sample-fulfillment.mjs` 完成样品采购、分次海外收货、装箱与面单、预估冻结、国际交寄、国内收货、实际核费及寄客户。使用本轮已核验固定客户,再次核对会话归属,收货仓从正式采购候选复用;既有可用现金须至少 3 元,正常样本实扣 2.50 元,不充值、不修改授信、不创建客户或仓库。仍按原依赖展开;定向入口为 `--modules phase4-overseas-wallet-and-pallet-cost`。凭据来自显式环境文件,失败保留报告及 `reports/procurement-wallet-checkpoints/<marker>.json`,未知结果停写,同批次不自动重跑。合成图片仅作测试凭证,客户会话在 finally 退出。授信充值、贴标资金、托盘汇总及拆柜成本综合场景继续列为覆盖缺口;该分支不代表采购异常、退款与全部类型已验收。离线检查不代表已实际执行 TEST HTTP。
固定客户模式下 `suppliers` 执行既有供应商四类启用筛选、分页、统计、详情身份与平台作用域拒绝的只读检查,不再整模块跳过;缺少某类样本时保留详情覆盖缺口。新增、编辑、停用生命周期仍明确未验收,不创建或修改供应商。`acceptance-overview.mjs` 从执行入口直接输出 `fixedResourceSkippedModules`,区分默认注册与固定资源模式实际跳过的模块。
客户端首页八大业务的脚本覆盖审计统一通过 `node scripts/acceptance-overview.mjs` 的 `homepageBusinesses` 查看,逐项对应首页实际标题及路由,并由离线测试检查八个入口无遗漏。输出区分默认模块、补充模块、固定资源跳过项、已写场景与剩余闭环;这是源码覆盖审计,不是当前 HTTP 或页面通过证明。审核通过、其他样本的 PDA 作业和独立专项通过均不能替代同一业务单的后续履约闭环。
固定客户车队履约通过显式环境文件的 `ADMIN_TEST_FLEET_RESOURCES` JSON 数组配置已有资源,每项为 `vehicleId`、`driverId`(十进制字符串)。助手只读核验海外自有在役车辆、已审核启用司机及截至预期任务结束的派车资格,不创建车辆/司机、不上传或重审证照。当前 runner 中尚未完成资源释放的组合不得重复分配;全部占用或缺配置直接阻断,失败资源保持占用待排查。复用车辆按审核后真实车牌查询,并以本轮排车号核对唯一 Fleet 绑定,不能把历史同车次记录当作当前证据。跨 runner 仍须串行,真实占用、仓与容量由正式分配接口最终校验。本规则不减少必要的业务排车与履约证据,也不覆盖车辆或司机主档生命周期。
国内整柜权限拒绝探针通过显式环境文件的 `ADMIN_TEST_DOMESTIC_DENY_ACTOR` JSON 对象配置 `membershipId`、`username`、`password`。固定客户模式在业务写入前核对既有 ACTIVE 平台账号及实际 A0403 拒绝响应,结束退出本轮会话;缺配置或权限不符直接阻断,不创建角色、账号或重置密码。实际凭据不得写入仓库或报告。
@@ -14,7 +36,7 @@
固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。
固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
固定客户模式下,默认编排排除 `system`、`master-data`、`notifications`、`pricing`、`phase4-overseas-master-data` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。
Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。
@@ -146,7 +168,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖
同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。
`phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。
`phase4-overseas-return-followups.mjs` 已接入默认全量,在退货签收合箱后执行换标、客户确认及普通/换标后出库;显式只选签收模块仍不运行后续。前置必须是同批次、同客户、主链断言通过的退货资源,仓库严格沿用实际退货收货仓并核对当前启用状态,不回退其他仓。固定客户模式保留跨客户、跨仓和并发边界缺口,不创建第二客户;后续复用既有 PDA 池,两个模块均退出本轮客户会话。写请求网络结果未知不自动重试。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;当前只补脚本,真实 HTTP 及页面仍待执行。
退货后续模块对普通退货出库及换标后退货出库统一核对最新 PDA 扫描位置、逐 SKU 总量/可用量/箱数扣减、唯一负 OUTBOUND 流水及历史箱明细保留,并在拣货重放、错误载荷与重复拣货拒绝、完成及完成重放后回查事实不变;库存及流水按本轮客户和仓库完整分页,缺失或截断直接失败。作用域内串行执行,公开 API 流水按行数、类型、出库号、SKU 和增量判唯一;该证据不代替底层数据库并发验证。仅状态推进或离线合同通过不能宣称真实出库记账通过。
@@ -329,7 +351,7 @@ export async function run(context) {
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(国内报关资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-container-documents.mjs`(离仓后目的港清关、DO;PU 使用柜级数字码)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
| 国内出仓聚合、门禁、待装柜与仓库权限 | `phase3-outbound-aggregate.mjs`、`phase3-dispatch-gate.mjs`、`phase3-waiting-for-loading.mjs`、`phase3-warehouse-rbac.mjs` | 由容器回归、生产者链和出仓相关合同覆盖;只消费本轮主链资源 |
| 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;依赖本轮海外签收,保留有有效余量根订单的 `DELIVERED`;完成订单样本经 `phase5-root-fulfillment.mjs` 两票真实签收和只读完成 Outbox 核验产生,不调用已移除的人工 `/complete`,并继续验证装柜完成、报关和订舱状态组合 |
| 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外业务 HTTP:采购创建/备注/审核边界,独立卡车原单履约完成,保留散板原单供默认主链接车 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
| 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` |
| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 |
| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(本轮已收货托盘和排车,PDA 拣货、资料齐全后装车自动发车、只读单据守恒、签收与回推;固定 PDA 账号退出会话) | `phase5-overseas-fulfillment-contract.test.mjs` |
@@ -0,0 +1,89 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyCargoDiscardFulfillment } from './cargo-discard-fulfillment.mjs'
function harness(fault = '') {
const calls = [], persisted = []
let discarded = false, confirmed = false, repeats = 0
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',
status: 'PENDING', skus: [{ itemId: '301', skuCode: proof.skuCode, quantityUnit: '箱', plannedPackages: 1 }], cartonMarks: ['BOX-TEST'] }
const context = { marker: 'DISCARD-TEST', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasClientServices: { marker: 'DISCARD-TEST' } },
assert(_module, name, _response, passed) { assert.ok(passed, name) },
async request(method, path, body, token) {
calls.push({ method, path, body, token })
assert.equal(persisted.at(-1)?.requests.at(-1)?.status, 'SENDING')
let data
if (method === 'GET') data = [{ ...task, customerId: fault === 'wrong customer' ? '999' : '101' }]
else if (path.endsWith('/resolve')) data = task
else if (path.endsWith('/confirm-completion')) { confirmed = true; data = {} }
else if (path.endsWith('/confirm')) {
if (fault === 'unknown confirm') throw new Error('network unknown')
if (discarded) repeats++
discarded = true
data = { ...task, status: 'COMPLETED', skus: [{ ...task.skus[0], actualPackages: 1 }] }
} else throw new Error(`unexpected ${path}`)
return { http: 200, body: { code: '200', data } }
},
async requestMultipart(method, path, form, token) {
calls.push({ method, path, token })
assert.ok(form.get('file'))
assert.equal(persisted.at(-1)?.requests.at(-1)?.status, 'SENDING')
return { http: 200, body: { code: '200', data: { fileId: '401', palletNo: task.palletNo } } }
},
}
const inputs = { proof, merchantToken: 'client-secret', pdaToken: 'pda-secret',
async checkpoint() {
if (fault === 'disk') throw new Error('disk full')
persisted.push(structuredClone(proof.fulfillment))
},
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,
skuCode: proof.skuCode, inboundCode: proof.inboundCode }] : []
if (discarded && fault === 'duplicate ledger') ledger.push({ ...ledger[0] })
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 } } }
} }
return { context, inputs, calls, persisted }
}
test('discard executes same application through PDA and customer completion with zero-drift replays', async () => {
const h = harness()
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-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'))
assert.ok(!JSON.stringify(h.persisted).includes('pda-secret'))
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /已批准申请|不自动重跑/)
})
for (const fault of ['wrong customer', 'no deduction', 'duplicate ledger', 'replay deduction', 'unknown confirm', 'disk']) {
test(`discard stops on ${fault}`, async () => {
const h = harness(fault)
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs))
assert.equal(h.inputs.proof.fulfillment.status, 'FAILED')
assert.equal(h.calls.some(call => call.path.endsWith('/confirm-completion')), false)
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.inputs.proof.fulfillment.requests.at(-1).status, 'UNKNOWN_RESULT')
}
})
}
test('foreign batch or customer fails before HTTP or checkpoint', async () => {
const h = harness()
h.inputs.proof.customerId = '999'
await assert.rejects(verifyCargoDiscardFulfillment(h.context, h.inputs), /本轮已批准申请/)
assert.equal(h.calls.length, 0)
assert.equal(h.persisted.length, 0)
})
@@ -0,0 +1,114 @@
import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-client-services'
const PDA = '/api/pda/v1/last-leg/cargo-discards'
const CLIENT = '/api/merchant/v1/last-leg/cargo-discards'
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)
const integer = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value))
const ok = response => response?.http === 200 && String(response?.body?.code) === '200'
/** 仅衔接本轮单箱一件代发废弃申请;所有写入前保存请求,未知结果不重试。 */
export async function verifyCargoDiscardFulfillment(context, { proof, merchantToken, pdaToken, snapshot, checkpoint }) {
if (!proof?.approvedNo || proof.status !== 'APPROVAL_HTTP_VERIFIED_CARTON_SQL_PENDING'
|| !merchantToken || !pdaToken || typeof snapshot !== 'function'
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|| context.createdResources.phase4OverseasClientServices?.marker !== context.marker) {
throw new Error('废弃履约缺少本轮已批准申请、客户或PDA会话')
}
if (proof.fulfillment) throw new Error('已有废弃履约证据,不自动重跑')
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw new Error('废弃批次标识不合法')
const evidence = proof.fulfillment = { marker: context.marker, status: 'RUNNING', requests: [], snapshots: {} }
let initialized = false
const directory = new URL('../../reports/cargo-discard-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 require = async (name, passed) => {
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' }
evidence.requests.push(entry); await persist()
let response
try { response = upload ? await context.requestMultipart(method, path, body, token)
: await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
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()
await require(`废弃履约 ${method} ${path}`, ok(response))
return response.body.data
}
const capture = async name => {
const state = await snapshot()
evidence.snapshots[name] = structuredClone(state); await persist(); return state
}
const matchTask = task => task?.discardNo === proof.approvedNo && task?.customerId === proof.customerId
&& task?.overseasWarehouseId === proof.warehouseId && typeof task?.palletNo === 'string' && task.palletNo.length > 0
try {
await persist()
const before = await capture('before')
await require('废弃执行前仍为同客户同仓已批准单箱申请', before.detail?.summary?.discardNo === proof.approvedNo
&& before.detail.summary.customerId === proof.customerId && before.detail.summary.overseasWarehouseId === proof.warehouseId
&& before.detail.summary.status === 'APPROVED' && Number(before.detail.summary.totalPackages) === 1)
const tasks = await command('GET', PDA, undefined, pdaToken)
const selected = Array.isArray(tasks) ? tasks.filter(task => task.discardNo === proof.approvedNo) : []
await require('本轮废弃任务唯一且客户仓库一致', selected.length === 1 && matchTask(selected[0]))
const task = selected[0]
await require('单箱废弃任务明细和真实箱唛完整', task.status === 'PENDING' && task.skus?.length === 1
&& task.skus[0].skuCode === proof.skuCode && task.skus[0].quantityUnit === '箱'
&& Number(task.skus[0].plannedPackages) === 1 && /^[1-9]\d*$/.test(String(task.skus[0].itemId))
&& task.cartonMarks?.length === 1 && typeof task.cartonMarks[0] === 'string' && task.cartonMarks[0].length > 0)
const resolved = await command('POST', `${PDA}/resolve`, { discardNo: proof.approvedNo, palletNo: task.palletNo }, pdaToken)
await require('PDA扫码解析保持原任务及明细', stable(resolved) === stable(task))
const root = `${PDA}/${encodeURIComponent(proof.approvedNo)}/pallets/${encodeURIComponent(task.palletNo)}`
const form = new FormData()
form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')],
{ type: 'image/png' }), `${context.marker}-synthetic-discard.png`)
const photo = await command('POST', `${root}/photos/files`, form, pdaToken, true)
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 completedTask = await command('POST', `${root}/confirm`, body, pdaToken)
await require('PDA完成保留原任务身份和实际数量', matchTask(completedTask) && completedTask.palletNo === task.palletNo
&& completedTask.status === 'COMPLETED' && completedTask.skus?.length === 1
&& String(completedTask.skus[0].itemId) === String(task.skus[0].itemId)
&& Number(completedTask.skus[0].actualPackages) === 1)
const completed = await capture('pdaCompleted')
await require('客户详情等待客户确认且实际废弃数量为一箱', completed.detail?.summary?.status === 'PDA_COMPLETED'
&& completed.detail.summary.discardNo === proof.approvedNo && Number(completed.detail.summary.actualPackages) === 1)
const scopedBalances = state => state.balances.filter(row => row.inboundCode === proof.inboundCode && row.skuCode === proof.skuCode)
const prior = scopedBalances(before), after = scopedBalances(completed)
await require('废弃库存快照包含同一来源余额', prior.length > 0 && prior.length === after.length
&& [...prior, ...after].every(row => integer(row.totalPackages) && integer(row.availablePackages)))
const sum = (rows, field) => rows.reduce((value, row) => value + Number(row[field]), 0)
await require('PDA废弃只扣本来源一箱总量和可用量', sum(prior, 'totalPackages') - sum(after, 'totalPackages') === 1
&& sum(prior, 'availablePackages') - sum(after, 'availablePackages') === 1)
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)
await command('POST', `${root}/confirm`, body, pdaToken)
await require('PDA完成重放库存与单据零漂移', stable(await capture('pdaReplay')) === stable(completed))
const clientPath = `${CLIENT}/${encodeURIComponent(proof.approvedNo)}/confirm-completion`
await command('POST', clientPath, undefined, merchantToken)
const confirmed = await capture('customerCompleted')
await require('客户确认完成且库存不再扣减', confirmed.detail?.summary?.status === 'COMPLETED'
&& Boolean(confirmed.detail.summary.customerConfirmedAt)
&& stable(confirmed.balances) === stable(completed.balances) && stable(confirmed.ledger) === stable(completed.ledger))
await command('POST', clientPath, undefined, merchantToken)
await command('POST', `${root}/confirm`, body, pdaToken)
await require('客户及PDA终态重放保持原完成事实', stable(await capture('terminalReplay')) === stable(confirmed))
evidence.status = 'HTTP_VERIFIED'; proof.status = 'COMPLETED_HTTP_VERIFIED_CARTON_SQL_PENDING'
proof.remainingEvidence = '公共库存总账已核对;独立箱、SKU及箱级流水底层证据仍待验证。'
await persist()
return evidence
} catch (error) {
evidence.status = 'FAILED'; evidence.failure = error.message; await persist(); throw error
}
}
@@ -0,0 +1,90 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
function harness(fault = '') {
const calls = [], saved = [], commands = new Set()
const proof = { marker: 'RELABEL-TEST', relabelNo: 'TB202610220001', customerId: '101', warehouseId: '201',
sourcePositionId: '301', sourcePalletNo: 'SOURCE', sourceOrderNo: 'WC202610220001' }
let status = 'PENDING_REVIEW', moved = false, scanned = false, version = 0, taskStatus = 'AVAILABLE'
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,
overseasWarehouseId: fault === 'wrong warehouse' ? '999' : '201', status,
walletStatus: fault === 'no funds' ? 'PENDING' : 'FROZEN', fulfillmentStatus: status === 'READY_FOR_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] })
const context = { marker: proof.marker, token: 'admin-token', context: { merchantQualificationFixture: { tenantId: '101' } },
assert(_module, name, _response, passed) { assert.ok(passed, name) },
async request(method, path, body, token) {
calls.push({ method, path, body, token })
assert.equal(saved.at(-1)?.requests.at(-1)?.status, 'SENDING')
const repeated = body?.idempotencyKey && commands.has(body.idempotencyKey)
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('/claim')) { taskStatus = 'PROCESSING'; if (!repeated) version++; data = task() }
else if (path.endsWith('/cartons/scan')) { if (!repeated) version++; data = task() }
else if (path.endsWith('/units')) {
if (fault === 'unknown scan') throw new Error('unknown scan response')
scanned = true; if (!repeated) version++; data = task()
if (fault === 'scan replay drift' && repeated) data.relabelledPackages = 2
} else if (path.endsWith('/relabel-pallet-label')) data = { palletId: '501', palletCode: 'TARGET' }
else if (path.endsWith('/pallets/resolve')) {
const source = body.code === 'SOURCE'
data = { palletCode: body.code, palletVersion: moved ? 1 : 0,
totalPackages: source ? moved ? fault === 'no source deduction' ? 3 : 2 : 3 : moved ? 1 : 0 }
} else if (path.endsWith('/relabel-disassemble')) { moved = true; data = { status: 'COMPLETED', operationId: '601', replayed: Boolean(repeated) } }
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 data = detail()
return { http: 200, body: { code: '200', data } }
},
async requestMultipart(method, path, body, token) {
calls.push({ method, path, token })
assert.equal(saved.at(-1)?.requests.at(-1)?.status, 'SENDING')
return { http: 200, body: { code: '200', data: { fileId: '701' } } }
} }
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)) } }
return { context, inputs, calls, saved }
}
test('original customer relabel reaches ready for dispatch through real command sequence and exact replays', async () => {
const h = harness()
const result = await verifyClientRelabelFulfillment(h.context, h.inputs)
assert.equal(result.status, 'READY_FOR_OUTBOUND')
assert.deepEqual(result.targetPalletIds, ['501'])
assert.equal(result.execution.status, 'HTTP_VERIFIED_READY_FOR_DISPATCH')
assert.equal(h.calls.filter(call => call.path.endsWith('/units')).length, 2)
assert.equal(h.calls.filter(call => call.path.endsWith('/relabel-disassemble')).length, 2)
assert.equal(h.calls.filter(call => call.path.endsWith('/confirm-completion')).length, 2)
assert.ok(!JSON.stringify(h.saved).includes('client-secret'))
assert.ok(!JSON.stringify(h.saved).includes('pda-secret'))
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /不自动重跑/)
})
for (const fault of ['wrong customer', 'wrong warehouse', 'no funds', 'unknown scan', 'scan replay drift', 'no source deduction', 'disk']) {
test(`relabel stops before completion on ${fault}`, async () => {
const h = harness(fault)
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs))
assert.equal(h.inputs.proof.execution.status, 'FAILED')
assert.equal(h.calls.some(call => call.path.endsWith('/confirm-completion')), false)
if (fault === 'no funds') assert.equal(h.calls.some(call => call.path.endsWith('/claim')), false)
if (fault === 'unknown scan') {
assert.equal(h.calls.filter(call => call.path.endsWith('/units')).length, 1)
assert.equal(h.inputs.proof.execution.requests.at(-1).status, 'UNKNOWN_RESULT')
}
if (fault === 'disk') assert.equal(h.calls.length, 0)
})
}
test('foreign-batch relabel is rejected before any HTTP', async () => {
const h = harness()
h.inputs.proof.marker = 'OLD'
await assert.rejects(verifyClientRelabelFulfillment(h.context, h.inputs), /本轮客户申请/)
assert.equal(h.calls.length, 0)
})
@@ -0,0 +1,132 @@
import { mkdir, writeFile } from 'node:fs/promises'
const MODULE = 'phase4-overseas-client-services'
const PALLETS = '/api/pda/v1/last-leg/pallet-operations'
const validId = value => /^[1-9]\d*$/.test(String(value))
const count = value => /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(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 verifyClientRelabelFulfillment(context, { proof, merchantToken, pdaToken, checkpoint, delay = () => new Promise(resolve => setTimeout(resolve, 1000)) }) {
if (proof?.marker !== context.marker || !proof?.relabelNo || !merchantToken || !pdaToken
|| proof.customerId !== String(context.context?.merchantQualificationFixture?.tenantId)
|| !validId(proof.warehouseId) || !validId(proof.sourcePositionId) || !proof.sourcePalletNo) {
throw new Error('贴标后续缺少本轮客户申请、来源仓位及会话')
}
if (proof.execution) throw new Error('已有贴标执行证据,不自动重跑')
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 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 require = (name, passed) => {
context.assert(MODULE, name, null, Boolean(passed), '', { scenario: 'client-relabel-fulfillment', deploymentBlocker: true })
if (!passed) throw new Error(name)
}
const key = action => `${context.marker}-CLIENT-RELABEL-${action}`
const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(proof.relabelNo)}`
const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(proof.relabelNo)}`
async function command(method, path, body, token, upload = false) {
const entry = { method, path, body: upload ? { syntheticImage: true } : body, status: 'SENDING' }
evidence.requests.push(entry); await persist()
let response
try { response = upload ? await context.requestMultipart(method, path, body, token)
: await context.request(method, path, body, token) }
catch (error) { entry.status = 'UNKNOWN_RESULT'; await persist(); throw error }
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()
require(`贴标后续 ${method} ${path}`, response?.http === 200 && String(response?.body?.code) === '200')
return response.body.data
}
const read = () => command('GET', platform, undefined, context.token)
const sameApplication = detail => detail?.application?.relabelNo === proof.relabelNo
&& detail.application.customerOrderNo === proof.sourceOrderNo
&& String(detail.application.overseasWarehouseId) === proof.warehouseId
const resolve = palletCode => command('POST', `${PALLETS}/pallets/resolve`, { code: palletCode }, pdaToken)
try {
await persist()
const profile = await command('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchantToken)
require('贴标当前客户会话身份有效', String(profile?.id) === proof.customerId && profile?.status === 'ACTIVE')
const owned = await command('GET', merchant, undefined, merchantToken)
require('客户可读取同一贴标申请', sameApplication(owned))
const original = await read()
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('贴标审核推进同一申请', sameApplication(reviewed) && reviewed.application.status === 'PENDING_PROCESSING')
let funded
for (let attempt = 0; attempt < 31; attempt++) {
funded = await read()
require('贴标等待资金期间申请身份不变', sameApplication(funded))
if (funded.application.walletStatus === 'FROZEN') break
if (attempt < 30) await delay()
}
require('贴标费用已冻结后才开始PDA作业', funded.application.walletStatus === 'FROZEN')
const claim = { idempotencyKey: key('claim') }
let task = await command('POST', `${work}/claim`, claim, pdaToken)
const replay = await command('POST', `${work}/claim`, claim, pdaToken)
require('认领原键重放不改变任务', stable(task) === stable(replay))
require('PDA原申请单箱任务及版本有效', task.relabelNo === proof.relabelNo && task.taskStatus === 'PROCESSING'
&& count(task.version) && task.items?.length === 1 && Number(task.items[0].expectedPackages) === 1)
const item = task.items[0]
require('PDA箱唛及SKU校验来自原申请', validId(item.itemId) && Boolean(item.cartonMark)
&& ['CARTON_AND_SKU', 'SKU_LABEL'].includes(item.operationType) && item.skuItems?.length > 0
&& item.skuItems.every(sku => sku.skuCode && sku.newSkuCode && count(sku.quantity) && Number(sku.quantity) > 0))
if (item.operationType === 'CARTON_AND_SKU') task = await command('POST', `${work}/cartons/scan`,
{ cartonMark: item.cartonMark, expectedVersion: Number(task.version), idempotencyKey: key('carton') }, pdaToken)
const scan = { itemId: String(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark || item.newSkuCode,
skuVerifications: item.skuItems.map(sku => ({ skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity) })),
expectedVersion: Number(task.version), idempotencyKey: key('unit') }
task = await command('POST', `${work}/units`, scan, pdaToken)
const scannedReplay = await command('POST', `${work}/units`, scan, pdaToken)
require('单箱贴标完成且扫描重放不重复计数', task.relabelNo === proof.relabelNo && Number(task.relabelledPackages) === 1
&& Number(task.skippedPackages) === 0 && Number(task.exceptionPackages) === 0 && stable(task) === stable(scannedReplay))
const detail = await read()
require('组托前来源位置和箱唛仍属于原申请', sameApplication(detail) && detail.items?.length === 1
&& String(detail.items[0].sourceInventoryPositionId) === proof.sourcePositionId && detail.items[0].cartonMark === item.cartonMark)
const target = await command('POST', `${PALLETS}/relabel-pallet-label`, { relabelNo: proof.relabelNo, idempotencyKey: key('label') }, pdaToken)
require('换标生成本次业务目标托盘', validId(target?.palletId) && Boolean(target.palletCode) && target.palletCode !== proof.sourcePalletNo)
const from = await resolve(proof.sourcePalletNo), to = await resolve(target.palletCode)
require('源目标托盘数量版本有效', from?.palletCode === proof.sourcePalletNo && to?.palletCode === target.palletCode
&& [from, to].every(value => count(value?.totalPackages) && count(value?.palletVersion)))
const binding = { relabelNo: proof.relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) },
target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, cartonMarks: [item.cartonMark],
reason: context.marker, idempotencyKey: key('bind') }
const bound = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
const boundReplay = await command('POST', `${PALLETS}/relabel-disassemble`, binding, pdaToken)
const fromAfter = await resolve(proof.sourcePalletNo), toAfter = await resolve(target.palletCode)
require('换标原箱源减一目标增一且组托重放复用操作', validId(bound.operationId) && bound.status === 'COMPLETED' && boundReplay.replayed === true
&& String(bound.operationId) === String(boundReplay.operationId)
&& Number(fromAfter.totalPackages) === Number(from.totalPackages) - 1 && Number(toAfter.totalPackages) === Number(to.totalPackages) + 1)
task = await command('GET', work, undefined, pdaToken)
const photo = await command('POST', `${work}/photos/files`, { name: `${context.marker}-synthetic-relabel.png`, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') }, pdaToken, true)
require('换标完工图片返回正式文件引用', validId(photo?.fileId))
const complete = { expectedVersion: Number(task.version), photoFileIds: [String(photo.fileId)], manualCompletion: false,
idempotencyKey: key('complete'), remark: context.marker }
const completed = await command('POST', `${work}/complete`, complete, pdaToken)
const completedReplay = await command('POST', `${work}/complete`, complete, pdaToken)
require('PDA完工及重放等待客户确认', completed.relabelNo === proof.relabelNo && completed.applicationStatus === 'CUSTOMER_CONFIRM_PENDING'
&& stable(completed) === stable(completedReplay))
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'
&& stable(confirmed) === stable(finalReplay))
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
} 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 { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs'
function fixture(fault = '') {
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', finalStatus: 'PENDING_ACCEPTANCE' }
const tripProof = { marker: 'FT-independent', tripNo: 'TR-1', dispatchNo: 'DP-1', warehouseId: '42', vehicleId: '11', driverId: '12', stops: [{}, { destinationKey: 'FBA:TEST' }] }
const calls = [], stages = []; let reviewed = false, complete = false
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
assert(_m, label, _r, ok) { assert.ok(ok, label) }, async checkpoint() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (path.includes('/local-looseboard-orders/')) data = { summary: { orderNo: 'LB-1', customerId: '101',
status: complete ? 'COMPLETED' : 'PENDING_ACCEPTANCE', estimatedPalletCount: 2, packageCount: 3,
fleetArrangement: complete ? { fulfillmentStage: 'COMPLETED', bookingNo: 'BK-1', tripNo: 'TR-1', dispatchNo: 'DP-1',
incomingNo: 'IN-1', deliveryNo: 'DE-1', signedPackages: fault === 'signed quantity' ? 2 : 3 } : null } }
else if (path.includes('/inventory/ledger?')) data = { total: 1, records: [{ id: '91', businessOrderNo: 'IN-1', businessType: 'STOCK_IN',
customerId: '101', overseasWarehouseId: '42', deltaPackages: fault === 'ledger' ? 4 : 3 }] }
else if (method === 'POST') {
stages.push(path.split('/').at(-1))
reviewed = stages.length === 3
data = { recordVersion: stages.length + 1 }
} else data = { dispatchNo: 'DP-1', stage: reviewed ? 'LOADING' : 'BEFORE_LOADING', recordVersion: 1, counts: { pickedPackageCount: 0 },
reviewState: 'APPROVED', reviews: { dispatchReview: { status: 'APPROVED' }, loadingReview: { status: 'APPROVED' } },
vehicleInfo: { vehicle: { vehicleId: fault === 'vehicle' ? '99' : '11' }, driver: { driverId: '12' } } }
return { http: 200, body: { code: '200', data }, requestId: 'req', traceId: 'trace' }
} }
const helpers = {
async prepare(ctx, args) {
assert.equal(ctx, context); assert.equal(args.demand, demand); assert.equal(args.tripProof, tripProof)
demand.poolBinding = { bookingNo: 'BK-1', createRequest: { cargo: { packageCount: 3 } } }
},
async receive(ctx, args) {
assert.equal(args.pdaToken, 'pda'); assert.deepEqual(args.measurements.map(m => m.cartonCount), [2, 1])
demand.receipt = { incomingNo: 'IN-1' }
},
async arrange() { return { status: 'BOUND_AWAITING_REVIEW', dispatchNo: 'DP-1', addedItems: [{ sourceKey: 'NEW-1' }, { sourceKey: 'NEW-2' }] } },
}
return { context, tripProof, demand, helpers, calls, stages, markCompleted: () => { complete = true } }
}
test('default orchestration uses original demand and existing trip, then verifies original signed order', async () => {
const f = fixture()
const result = await attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers)
assert.equal(result.proof.status, 'READY_FOR_SHARED_LOADING')
assert.deepEqual(f.stages, ['submit-review', 'dispatch-review', 'loading-review'])
assert.deepEqual(result.proof.sourceKeys, ['NEW-1', 'NEW-2'])
f.markCompleted()
await verifyLooseboardDelivered(f.context, f.tripProof, [{ deliveryNo: 'DE-1', customerId: '101' }])
assert.equal(f.demand.finalStatus, 'COMPLETED')
assert.equal(f.demand.fulfillment.status, 'COMPLETED_HTTP_VERIFIED')
})
for (const fault of ['ledger', 'vehicle']) test(`orchestration stops on ${fault}`, async () => {
const f = fixture(fault)
await assert.rejects(attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers))
assert.equal(f.demand.fulfillment.status, 'FAILED')
if (fault === 'ledger') assert.equal(f.stages.length, 0)
})
test('whole vehicle signed does not substitute for original looseboard signed quantity', async () => {
const f = fixture('signed quantity')
await attachLooseboardToTrip(f.context, f.tripProof, 'pda', f.helpers)
f.markCompleted()
await assert.rejects(verifyLooseboardDelivered(f.context, f.tripProof, [{ deliveryNo: 'DE-1', customerId: '101' }]))
assert.notEqual(f.demand.fulfillment.status, 'COMPLETED_HTTP_VERIFIED')
})
@@ -0,0 +1,85 @@
import { prepareLooseboardPool } from './looseboard-pool-preparation.mjs'
import { receiveLooseboardPool } from './looseboard-pool-receipt.mjs'
import { arrangeLooseboardPool } from './looseboard-pool-dispatch.mjs'
import { dispatchIo, version } from './grouped-dispatch-acceptance.mjs'
/** 默认主链复用原散板原车;测量仅为明确TEST样本,不代表生产实测。 */
export async function attachLooseboardToTrip(context, tripProof, pdaToken,
helpers = { prepare: prepareLooseboardPool, receive: receiveLooseboardPool, arrange: arrangeLooseboardPool }) {
const demand = context.createdResources?.phase4OverseasBusiness?.looseboard
if (!demand || demand.marker !== context.marker || demand.finalStatus !== 'PENDING_ACCEPTANCE'
|| !context.createdResources?.fleetTrips?.includes(tripProof) || !pdaToken || demand.fulfillment) throw Error('默认散板履约缺少本轮待接车原单或已执行')
const proof = demand.fulfillment = { marker: context.marker, status: 'ATTACHING', requests: [] }
const io = dispatchIo(context, 'phase5-overseas-fulfillment', proof, 'LOOSEBOARD-MAIN')
const check = (name, ok) => io.check(name, null, Boolean(ok))
try {
const original = await io.command('GET', `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`, undefined, '读取原散板货量')
const row = original?.summary
check('原散板身份和货量可供受控实收', row?.orderNo === demand.orderNo && String(row.customerId) === String(demand.customerId)
&& row.status === 'PENDING_ACCEPTANCE' && Number.isSafeInteger(row.estimatedPalletCount) && row.estimatedPalletCount > 0
&& Number.isSafeInteger(row.packageCount) && row.packageCount >= row.estimatedPalletCount)
const destinationKey = tripProof.stops?.[1]?.destinationKey
check('原车存在明确卸货目的地', typeof destinationKey === 'string' && destinationKey.length > 0)
// 当前受控样本以每实托100×100×90cm、40kg执行,原单箱数均分且总数守恒。
const measurements = Array.from({ length: row.estimatedPalletCount }, (_, index) => ({
cartonCount: Math.floor(row.packageCount / row.estimatedPalletCount) + (index < row.packageCount % row.estimatedPalletCount ? 1 : 0),
lengthCm: 100, widthCm: 100, heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40,
}))
proof.measurements = { kind: 'CONTROLLED_TEST_MEASUREMENT', measurements }
await helpers.prepare(context, { demand, tripProof, destinationKey, unitPrice: 25 })
await helpers.receive(context, { demand, tripProof, pdaToken, measurements })
const ledger = await io.command('GET', `/api/platform/v1/last-leg/inventory/ledger?${new URLSearchParams({
customerId: String(demand.customerId), overseasWarehouseId: String(tripProof.warehouseId),
businessOrderNo: demand.receipt.incomingNo, businessType: 'STOCK_IN', page: '1', size: '200',
})}`, undefined, '独立核验原散板来货入库流水')
check('散板实收仅产生足量且唯一的本票入库流水', Array.isArray(ledger?.records) && ledger.records.length > 0
&& Number(ledger.total) === ledger.records.length && new Set(ledger.records.map(r => String(r.id))).size === ledger.records.length
&& ledger.records.every(r => /^[1-9]\d*$/.test(String(r.id)) && r.businessOrderNo === demand.receipt.incomingNo
&& r.businessType === 'STOCK_IN' && String(r.customerId) === String(demand.customerId)
&& String(r.overseasWarehouseId) === String(tripProof.warehouseId)
&& Number.isSafeInteger(Number(r.deltaPackages)) && Number(r.deltaPackages) > 0)
&& ledger.records.reduce((sum, r) => sum + Number(r.deltaPackages), 0) === row.packageCount)
proof.stockInLedger = ledger.records
const arranged = await helpers.arrange(context, { demand, tripProof })
check('原散板全部实收货物已经加入本车', arranged?.status === 'BOUND_AWAITING_REVIEW' && arranged.dispatchNo === tripProof.dispatchNo
&& arranged.addedItems?.length === measurements.length && new Set(arranged.addedItems.map(r => r.sourceKey)).size === measurements.length)
const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`
let current = await io.command('GET', path, undefined, '散板加入后重新审核原车')
check('加货后仅在原车装车前重新审核', current?.dispatchNo === tripProof.dispatchNo && current.stage === 'BEFORE_LOADING'
&& Number(current.counts?.pickedPackageCount) === 0)
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
current = await io.command('POST', `${path}/${action}`, { recordVersion: version(current.recordVersion), idempotencyKey: io.key(action),
...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }, `散板加货后${action}`)
}
const reviewed = await io.command('GET', path, undefined, '核对加货后两级审核及原车资源')
check('加货审核后原车资源不变且进入装车', reviewed?.stage === 'LOADING' && reviewed.reviewState === 'APPROVED'
&& reviewed.reviews?.dispatchReview?.status === 'APPROVED' && reviewed.reviews?.loadingReview?.status === 'APPROVED'
&& String(reviewed.vehicleInfo?.vehicle?.vehicleId) === String(tripProof.vehicleId)
&& String(reviewed.vehicleInfo?.driver?.driverId) === String(tripProof.driverId))
proof.status = 'READY_FOR_SHARED_LOADING'
proof.sourceKeys = arranged.addedItems.map(row => row.sourceKey)
proof.incomingNo = demand.receipt.incomingNo
await context.checkpoint()
return { proof, detail: reviewed }
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error
}
}
/** 同车正常签收后回查原散板商业单,不能用整车状态代替原单履约。 */
export async function verifyLooseboardDelivered(context, tripProof, deliveries) {
const demand = context.createdResources?.phase4OverseasBusiness?.looseboard, proof = demand?.fulfillment
if (demand?.marker !== context.marker || proof?.status !== 'READY_FOR_SHARED_LOADING') throw Error('缺少本轮原散板装车前证据')
const io = dispatchIo(context, 'phase5-overseas-fulfillment', proof, 'LOOSEBOARD-COMPLETE')
const detail = await io.command('GET', `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`, undefined, '签收后回查原散板终态')
const row = detail?.summary, arrangement = row?.fleetArrangement
const passed = row?.orderNo === demand.orderNo && String(row.customerId) === String(demand.customerId) && row.status === 'COMPLETED'
&& arrangement?.fulfillmentStage === 'COMPLETED' && arrangement.bookingNo === demand.poolBinding.bookingNo
&& arrangement.tripNo === tripProof.tripNo && arrangement.dispatchNo === tripProof.dispatchNo
&& arrangement.incomingNo === demand.receipt.incomingNo && Number(arrangement.signedPackages) === demand.poolBinding.createRequest.cargo.packageCount
&& deliveries.some(d => d.deliveryNo === arrangement.deliveryNo && String(d.customerId) === String(demand.customerId))
io.check('原散板通过真实同车签收完成且数量守恒', null, Boolean(passed))
Object.assign(proof, { status: 'COMPLETED_HTTP_VERIFIED', deliveryNo: arrangement.deliveryNo })
demand.finalStatus = 'COMPLETED'
await context.checkpoint()
}
@@ -0,0 +1,87 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
// 有状态模拟保留原单与独立询价,重放返回原响应,故障必须在后续写入前停止。
function fixture(fault = '') {
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', version: 2, finalStatus: 'PENDING_ACCEPTANCE' }
const tripProof = { tripNo: 'TR-1', warehouseId: '42', looseboardOffer: { marker: 'fixture', offerNo: 'OF-1' } }
const offer = { offerNo: 'OF-1', tripNo: 'TR-1', warehouseId: '42', status: 'OPEN', tripRecordVersion: 1, recordVersion: 0,
canCreateInquiry: true, canConfirm: true, capacityKnown: true,
destinations: [{ destinationKey: 'FBA:TEST', remaining: { palletSpaces: fault === 'capacity' ? 0 : 4, weightKg: 400, volumeCbm: 4 } }] }
const original = { remark: 'fixture-original', summary: { ...demand, status: 'PENDING_ACCEPTANCE', estimatedPalletCount: 2,
packageCount: 3, totalWeightKg: 80, totalVolumeCbm: 1.8, productName: 'TEST', currency: 'USD', originContactName: 'TEST', originContactPhone: '19900000000' } }
let created, confirmed, writes = 0
const calls = [], snapshots = []
const context = { marker: 'fixture', createdResources: { phase4OverseasBusiness: { looseboard: demand }, fleetTrips: [tripProof] },
assert(_m, name, _r, ok) { assert.ok(ok, name) }, apiSuccess() {},
async checkpoint() {
if (fault === 'disk') throw Error('disk unavailable')
snapshots.push(structuredClone(demand.poolBinding))
},
async request(method, path, body) {
calls.push({ method, path, body: structuredClone(body) })
if (method === 'POST') writes += 1
let data
if (path.includes('/local-looseboard-orders/')) {
data = structuredClone(original)
if (fault === 'customer') data.summary.customerId = '102'
if (confirmed) {
data.summary.status = 'WAITING_INBOUND'; data.summary.version += 1
data.summary.fleetArrangement = { businessOrderNo: 'LB-1', bookingNo: 'BK-1', tripNo: 'TR-1' }
if (fault === 'binding') data.summary.fleetArrangement.tripNo = 'OTHER'
}
} else if (method === 'GET' && path.endsWith('/OF-1')) data = { offer }
else if (method === 'GET' && path.endsWith('/BK-1')) data = created.inquiry
else if (method === 'POST' && path.endsWith('/OF-1/inquiries')) {
assert.equal(body.sourceOrderNo, 'LB-1'); assert.equal(body.sourceOrderVersion, 2)
assert.equal(body.customerId, '101'); assert.equal(body.expectedPalletCount, 2)
assert.equal(body.cargo.packageCount, 3)
if (fault === 'network') return { http: 0 }
if (!created) {
created = { inquiry: { ...body, offerNo: 'OF-1', tripNo: 'TR-1', bookingNo: 'BK-1', status: 'DRAFT', recordVersion: 0 }, offer }
data = created
} else {
data = { ...structuredClone(created), replayed: true }
if (fault === 'replay') data.inquiry.bookingNo = 'BK-2'
}
} else if (method === 'POST' && path.endsWith('/BK-1/confirm')) {
if (!confirmed) {
confirmed = { inquiry: { ...created.inquiry, status: 'CONFIRMED', recordVersion: 1, agreedAmount: fault === 'price' ? 25 : 50,
businessOrderNo: fault === 'new order' ? 'LB-2' : 'LB-1' }, offer }
data = confirmed
} else data = { ...confirmed, replayed: true }
} else throw Error(`Unexpected ${method} ${path}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }
} }
return { context, args: { demand, tripProof, offerNo: 'OF-1', destinationKey: 'FBA:TEST', unitPrice: 25 }, calls, snapshots, writes: () => writes }
}
test('pool binding preserves original demand and confirms only one commercial order', async () => {
const f = fixture()
const proof = await bindLooseboardDemand(f.context, f.args)
assert.equal(proof.status, 'CONFIRMED_ORIGINAL_ORDER')
assert.equal(f.args.demand.finalStatus, 'WAITING_INBOUND')
assert.equal(f.writes(), 4)
assert.equal(f.snapshots.at(-1).status, 'CONFIRMED_ORIGINAL_ORDER')
await assert.rejects(bindLooseboardDemand(f.context, f.args), /本轮原单/)
assert.equal(f.writes(), 4)
})
for (const [fault, maxWrites] of [['customer', 0], ['capacity', 0], ['network', 1], ['replay', 2], ['new order', 3], ['price', 3], ['binding', 4], ['disk', 0]]) {
test(`pool binding stops on ${fault} without another order or automatic retry`, async () => {
const f = fixture(fault)
await assert.rejects(bindLooseboardDemand(f.context, f.args))
assert.equal(f.args.demand.poolBinding.status, 'FAILED')
assert.equal(f.writes(), maxWrites)
assert.equal(f.calls.some(c => /cancel|\/iam\/|\/auth\/register/.test(c.path)), false)
})
}
test('foreign batch or unregistered trip is rejected before HTTP', async () => {
for (const alter of [f => { f.args.demand.marker = 'other' }, f => { f.context.createdResources.fleetTrips = [] }]) {
const f = fixture(); alter(f)
await assert.rejects(bindLooseboardDemand(f.context, f.args))
assert.equal(f.calls.length, 0)
}
})
@@ -0,0 +1,100 @@
const BASE = '/api/platform/v1/last-leg/fleet/capacity-offers'
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'looseboard-pool-binding', deploymentBlocker: true }
const version = value => Number.isSafeInteger(value) && value >= 0
/** 将本轮散板原单绑定本轮已发布车次余位,禁止无来源另建商业订单。 */
export async function bindLooseboardDemand(context, { demand, tripProof, offerNo, destinationKey, unitPrice }) {
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| demand.finalStatus !== 'PENDING_ACCEPTANCE' || demand.poolBinding
|| !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo
|| tripProof.looseboardOffer?.marker !== context.marker || tripProof.looseboardOffer?.offerNo !== offerNo
|| !destinationKey || !Number.isFinite(unitPrice) || unitPrice <= 0 || typeof context.checkpoint !== 'function') {
throw Error('散板匹配缺少本轮原单、车次、发布证据、目的地、约定价格或检查点')
}
const proof = demand.poolBinding = { marker: context.marker, offerNo, tripNo: tripProof.tripNo, status: 'CHECKING', requests: [] }
const check = (name, ok) => {
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${demand.orderNo}`, OPTIONS)
if (!ok) throw Error(name)
}
const request = async (method, path, body) => {
const log = { method, path, status: 'SENDING' }; proof.requests.push(log)
await context.checkpoint()
const response = await context.request(method, path, body)
Object.assign(log, { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
const success = response?.http === 200 && String(response?.body?.code) === '200'
log.status = success ? 'SUCCEEDED' : !response?.http || response.http >= 500 ? 'UNKNOWN_RESULT' : 'REJECTED'
await context.checkpoint()
context.apiSuccess(MODULE, `散板原单接车 ${method}`, response, JSON.stringify(log), OPTIONS)
if (!success) throw Error('散板接车请求失败,保留原单与请求证据,不自动重试')
return response.body.data
}
const originalPath = `/api/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(demand.orderNo)}?customerId=${encodeURIComponent(demand.customerId)}`
const validateInquiry = (row, status) => check('询价归属本轮原车与原客户', row?.tripNo === tripProof.tripNo && row.offerNo === offerNo
&& String(row.customerId) === String(demand.customerId) && row.businessType === 'LOCAL_LOOSEBOARD'
&& row.status === status && row.bookingNo && (!proof.bookingNo || row.bookingNo === proof.bookingNo)
&& version(row.recordVersion) && row.cargo?.destinationKey === destinationKey)
try {
const original = await request('GET', originalPath)
const summary = original?.summary
check('散板原单仍可接车且无已有承运', summary?.orderNo === demand.orderNo && String(summary.customerId) === String(demand.customerId)
&& summary.status === 'PENDING_ACCEPTANCE' && version(summary.version) && summary.version === demand.version
&& !summary.fleetArrangement && original.remark?.startsWith(context.marker))
const offer = (await request('GET', `${BASE}/${encodeURIComponent(offerNo)}`))?.offer
const destination = offer?.destinations?.find(row => row.destinationKey === destinationKey)
check('余位为本轮同仓开放车次且容量明确', offer?.offerNo === offerNo && offer.tripNo === tripProof.tripNo
&& String(offer.warehouseId) === String(tripProof.warehouseId) && offer.status === 'OPEN'
&& offer.canCreateInquiry && offer.canConfirm && offer.capacityKnown && destination
&& version(offer.tripRecordVersion) && version(offer.recordVersion))
const pallets = Number(summary.estimatedPalletCount), packages = Number(summary.packageCount)
const weight = Number(summary.totalWeightKg), volume = Number(summary.totalVolumeCbm)
check('接车货量沿用原散板单且不超过余位', Number.isSafeInteger(pallets) && pallets > 0 && Number.isSafeInteger(packages) && packages > 0
&& Number.isFinite(weight) && weight > 0 && Number.isFinite(volume) && volume > 0
&& destination.remaining?.palletSpaces >= pallets && destination.remaining?.weightKg >= weight && destination.remaining?.volumeCbm >= volume)
const body = proof.createRequest = { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion,
customerId: demand.customerId, businessType: 'LOCAL_LOOSEBOARD', deliveryMode: 'SELF_DELIVERY',
sourceOrderNo: demand.orderNo, sourceOrderVersion: summary.version, expectedPalletCount: pallets,
estimatedArrivalAt: new Date(Date.now() + 900000).toISOString(), idempotencyKey: `${context.marker}-looseboard-bind`,
cargo: { destinationKey, cargoName: summary.productName, packageCount: packages, palletSpaces: pallets,
weightKg: weight, volumeCbm: volume, priceBasis: 'PALLET', unitPrice, currency: summary.currency,
contactName: summary.originContactName, contactPhone: summary.originContactPhone, remark: context.marker } }
const created = await request('POST', `${BASE}/${encodeURIComponent(offerNo)}/inquiries`, body)
validateInquiry(created?.inquiry, 'DRAFT')
proof.bookingNo = created.inquiry.bookingNo
const replay = await request('POST', `${BASE}/${encodeURIComponent(offerNo)}/inquiries`, body)
check('原单询价重放保持唯一承运与版本', replay.replayed === true
&& JSON.stringify(replay.inquiry) === JSON.stringify(created.inquiry) && JSON.stringify(replay.offer) === JSON.stringify(created.offer))
const path = `${BASE}/inquiries/${encodeURIComponent(proof.bookingNo)}`
const latest = await request('GET', path)
validateInquiry(latest, 'DRAFT')
check('确认承运使用完整即时版本', version(latest.tripRecordVersion) && version(latest.offerRecordVersion))
const confirmBody = proof.confirmRequest = { tripRecordVersion: latest.tripRecordVersion, offerRecordVersion: latest.offerRecordVersion,
recordVersion: latest.recordVersion, idempotencyKey: `${context.marker}-looseboard-confirm`, reason: context.marker }
const confirmed = await request('POST', `${path}/confirm`, confirmBody)
validateInquiry(confirmed?.inquiry, 'CONFIRMED')
check('原单约定货量和运费没有漂移', confirmed.inquiry.cargo?.packageCount === packages
&& confirmed.inquiry.cargo?.palletSpaces === pallets && confirmed.inquiry.cargo?.currency === summary.currency
&& Number(confirmed.inquiry.cargo?.unitPrice) === unitPrice
&& Math.round(Number(confirmed.inquiry.agreedAmount) * 100) === Math.round(unitPrice * pallets * 100))
check('承运复用原散板单且尚未伪造来货或排车', confirmed.inquiry.businessOrderNo === demand.orderNo
&& !confirmed.inquiry.incomingNo && !confirmed.inquiry.dispatchNo)
const confirmReplay = await request('POST', `${path}/confirm`, confirmBody)
check('承运确认重放未生成第二单或重复占位', confirmReplay.replayed === true
&& JSON.stringify(confirmReplay.inquiry) === JSON.stringify(confirmed.inquiry) && JSON.stringify(confirmReplay.offer) === JSON.stringify(confirmed.offer))
const result = await request('GET', originalPath)
const arrangement = result?.summary?.fleetArrangement
check('原散板单回写同一承运并进入待入仓', result?.summary?.orderNo === demand.orderNo
&& String(result.summary.customerId) === String(demand.customerId) && result.summary.status === 'WAITING_INBOUND'
&& arrangement?.businessOrderNo === demand.orderNo && arrangement.bookingNo === proof.bookingNo && arrangement.tripNo === tripProof.tripNo
&& !arrangement.incomingNo && !arrangement.dispatchNo)
Object.assign(demand, { finalStatus: 'WAITING_INBOUND', version: result.summary.version, retained: true })
proof.status = 'CONFIRMED_ORIGINAL_ORDER'
await context.checkpoint()
return proof
} catch (error) {
for (const log of proof.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint()
throw error
}
}
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { arrangeLooseboardPool } from './looseboard-pool-dispatch.mjs'
function fixture(fault = '') {
const tripProof = { tripNo: 'TR-1', dispatchNo: 'DP-1' }
const pallet = { warehousePalletId: '72', palletNo: 'P-2', packages: 3 }
const receipt = { status: 'RECEIVED_READY_FOR_DISPATCH', incomingNo: 'IN-1', pallets: [pallet],
acceptedReceipt: { source: { receiptHash: 'hash', pallets: [{ ...pallet, weightKg: 80, volumeCbm: 1.8 }] } } }
const demand = { marker: 'fixture', customerId: '101', receipt, poolBinding: {
bookingNo: 'BK-1', tripNo: 'TR-1', createRequest: { cargo: { destinationKey: 'FBA:TEST' } } } }
const oldRow = { dispatchItemId: '1', sourceKey: 'OLD', customerId: '101', warehousePalletId: '71', inboundCode: 'OLD', scheduledPackages: 2, pickedPackages: 0 }
const newRow = { dispatchItemId: '2', sourceKey: 'NEW', customerId: '101', ...pallet, inboundCode: 'IN-1', scheduledPackages: 3, pickedPackages: 0 }
const planPallet = { warehousePalletId: '71', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', incomingNo: 'IN-1', status: 'RECEIVED', recordVersion: 2, receipt: receipt.acceptedReceipt }
let stage = 'LOADING', arranged = false, arrangeCalls = 0, returnCalls = 0
const calls = []
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
assert(_m, name, _r, ok) { assert.ok(ok, name) }, async checkpoint() {},
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (path.endsWith('/dispatches/DP-1')) data = { dispatchNo: 'DP-1', status: 'ACTIVE', stage, recordVersion: 1,
counts: { pickedPackageCount: fault === 'picked' ? 1 : 0, shippedPackageCount: 0 } }
else if (path.endsWith('/items')) {
data = { dispatchNo: 'DP-1', items: arranged ? [oldRow, newRow] : [oldRow] }
data = structuredClone(data)
if (arranged && fault === 'lost original') data.items.shift()
if (arranged && fault === 'wrong boxes') data.items[1].scheduledPackages = 4
} else if (path.endsWith('/return-to-review')) {
returnCalls += 1; stage = 'BEFORE_LOADING'
data = { dispatchNo: 'DP-1', action: 'RETURN_TO_REVIEW', replayed: returnCalls > 1 }
} else if (path.endsWith('/capacity-plan')) {
data = { planValid: !arranged, recordVersion: 1, manifestHash: 'hash', capacityPlan: { pallets: [planPallet] }, cargo: [{ dispatchNo: 'DP-1' }] }
if (method === 'POST') {
assert.deepEqual(body.pallets[0], planPallet)
assert.deepEqual(body.pallets[1], { warehousePalletId: '72', palletSpaces: 1, weightKg: 80, volumeCbm: 1.8, deliveryIndex: 1 })
data.planValid = true
}
} else if (path.endsWith('/bookings')) data = { tripRecordVersion: 2, records: [booking] }
else if (path.endsWith('/arrange-dispatch')) {
arrangeCalls += 1
assert.equal('dispatchNo' in body, false)
if (fault === 'network') return { http: 0 }
arranged = true
data = { tripRecordVersion: 3, booking: { ...booking, status: 'BOUND', dispatchNo: fault === 'wrong main' ? 'OTHER' : 'DP-1' }, replayed: arrangeCalls > 1 }
if (fault === 'replay' && arrangeCalls > 1) data.tripRecordVersion += 1
} else if (path.endsWith('/trips/TR-1')) data = { trip: { tripNo: 'TR-1', stops: [{ destinationKey: 'ORIGIN' }, { destinationKey: 'FBA:TEST' }] } }
else throw Error(`Unexpected ${method} ${path}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
} }
return { context, args: { demand, tripProof }, calls, counts: () => arrangeCalls }
}
test('original looseboard receipt joins same main preserving existing rows and planning', async () => {
const f = fixture()
const result = await arrangeLooseboardPool(f.context, f.args)
assert.equal(result.status, 'BOUND_AWAITING_REVIEW')
assert.equal(f.counts(), 2)
await assert.rejects(arrangeLooseboardPool(f.context, f.args))
assert.equal(f.counts(), 2)
})
for (const fault of ['picked', 'network', 'wrong main', 'replay', 'lost original', 'wrong boxes']) {
test(`join original main rejects ${fault} and preserves evidence`, async () => {
const f = fixture(fault)
await assert.rejects(arrangeLooseboardPool(f.context, f.args))
assert.equal(f.args.demand.receipt.dispatch.status, 'FAILED')
assert.equal(f.calls.some(c => c.method === 'POST' && c.path.endsWith('/capacity-plan')), false)
if (fault === 'network') {
assert.equal(f.counts(), 1)
assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true)
}
})
}
@@ -0,0 +1,86 @@
import { dispatchIo, replay, version } from './grouped-dispatch-acceptance.mjs'
const API = '/api/platform/v1/last-leg'
const id = value => String(value ?? '')
const rowFact = row => JSON.stringify([id(row.dispatchItemId), row.sourceKey, id(row.customerId), id(row.warehousePalletId),
row.inboundCode, Number(row.scheduledPackages), Number(row.pickedPackages)])
/** 把原散板实收货加入本车唯一主单,并核对既有货物和新增实托守恒。 */
export async function arrangeLooseboardPool(context, { demand, tripProof }) {
const receipt = demand?.receipt, binding = demand?.poolBinding
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|| receipt?.status !== 'RECEIVED_READY_FOR_DISPATCH' || !receipt.incomingNo || receipt.dispatch
|| !receipt.pallets?.length || typeof context.checkpoint !== 'function') throw Error('散板排入原车缺少本轮确认实收证据')
const proof = receipt.dispatch = { marker: context.marker, status: 'ARRANGING', requests: [], dispatchNo: tripProof.dispatchNo }
const io = dispatchIo(context, 'phase4-overseas-business', proof, `LOOSEBOARD-${binding.bookingNo}`)
const tripPath = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
const dispatchPath = `${API}/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`
const check = (name, ok) => io.check(name, null, Boolean(ok))
try {
let current = await io.command('GET', dispatchPath, undefined, '散板加货前原车详情')
const before = await io.command('GET', `${dispatchPath}/items`, undefined, '冻结原车既有货物')
check('原车仍未扫码且原货完整', current?.dispatchNo === tripProof.dispatchNo && current.status === 'ACTIVE'
&& ['BEFORE_LOADING', 'LOADING'].includes(current.stage) && Number(current.counts?.pickedPackageCount) === 0
&& Number(current.counts?.shippedPackageCount) === 0 && before?.dispatchNo === tripProof.dispatchNo
&& Array.isArray(before.items) && before.items.length > 0
&& before.items.every(row => row.inboundCode !== receipt.incomingNo && Number(row.pickedPackages) === 0))
const originalRows = before.items.map(rowFact)
const originalPlan = await io.command('GET', `${tripPath}/capacity-plan`, undefined, '冻结原车容量计划')
check('本车加货前容量计划有效', originalPlan?.planValid === true && !originalPlan.blockReason && originalPlan.capacityPlan?.pallets?.length > 0)
if (current.stage === 'LOADING') {
const body = { recordVersion: version(current.recordVersion), idempotencyKey: io.key('return-review'), reason: '本轮零扫码明确退回,加入散板原单实际实收货物' }
const result = await io.command('POST', `${dispatchPath}/return-to-review`, body, '原车零扫码退回审核')
await replay(io, `${dispatchPath}/return-to-review`, body, result, '退回审核同键重放')
current = await io.command('GET', dispatchPath, undefined, '回查原车可加货阶段')
}
check('原车已处装车前', current?.dispatchNo === tripProof.dispatchNo && current.stage === 'BEFORE_LOADING')
const list = await io.command('GET', `${tripPath}/bookings`, undefined, '读取原散板承运最新版本')
const rows = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || [], booking = rows[0]
check('仅把原承运原来货排入本车', rows.length === 1 && booking.tripNo === tripProof.tripNo
&& id(booking.customerId) === id(demand.customerId) && booking.incomingNo === receipt.incomingNo
&& booking.status === 'RECEIVED' && !booking.dispatchNo
&& booking.receipt?.source?.receiptHash === receipt.acceptedReceipt?.source?.receiptHash)
const body = proof.arrangeRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(booking.recordVersion), idempotencyKey: io.key('arrange') }
const path = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}/arrange-dispatch`
const arranged = await io.command('POST', path, body, '正式接口排入本车唯一主单')
check('排入结果绑定同一主单和来源', arranged?.booking?.status === 'BOUND' && arranged.booking.bookingNo === binding.bookingNo
&& arranged.booking.tripNo === tripProof.tripNo && arranged.booking.dispatchNo === tripProof.dispatchNo
&& arranged.booking.incomingNo === receipt.incomingNo && id(arranged.booking.customerId) === id(demand.customerId))
const again = await replay(io, path, body, arranged, '排入本车同键重放')
check('排入重放明确返回原结果', again?.replayed === true)
const after = await io.command('GET', `${dispatchPath}/items`, undefined, '加货后回查全部真实货物')
const added = after?.items?.filter(row => row.inboundCode === receipt.incomingNo) || []
check('加货不删除或重复既有货物', after?.dispatchNo === tripProof.dispatchNo && after.items?.length === before.items.length + receipt.pallets.length
&& originalRows.every(fact => after.items.filter(row => rowFact(row) === fact).length === 1))
check('新增行精确对应原散板实收各托箱数', added.length === receipt.pallets.length
&& receipt.pallets.every(p => added.filter(row => id(row.warehousePalletId) === id(p.warehousePalletId)
&& id(row.customerId) === id(demand.customerId) && row.palletNo === p.palletNo
&& Number(row.scheduledPackages) === p.packages && Number(row.pickedPackages) === 0).length === 1))
const plan = await io.command('GET', `${tripPath}/capacity-plan`, undefined, '新增实收后读取容量依据')
const detail = await io.command('GET', tripPath, undefined, '读取原车真实卸货站')
check('容量货物仅属于本车唯一主单', plan?.planValid === false && plan.cargo?.every(row => row.dispatchNo === tripProof.dispatchNo))
const destinationKey = binding.createRequest.cargo.destinationKey
const stopIndexes = detail?.trip?.stops?.map((stop, index) => stop.destinationKey === destinationKey ? index : -1).filter(index => index > 0) || []
check('实收目的地对应唯一卸货站', detail?.trip?.tripNo === tripProof.tripNo && stopIndexes.length === 1)
const pallets = structuredClone(originalPlan.capacityPlan.pallets)
for (const p of receipt.pallets) {
const measured = receipt.acceptedReceipt?.source?.pallets?.find(row => id(row.warehousePalletId) === id(p.warehousePalletId))
check('新增容量仅用实际实托及实测', measured && Number(measured.weightKg) > 0 && Number(measured.volumeCbm) > 0
&& !pallets.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)))
pallets.push({ warehousePalletId: p.warehousePalletId, palletSpaces: 1,
weightKg: Number(measured.weightKg), volumeCbm: Number(measured.volumeCbm), deliveryIndex: stopIndexes[0] })
}
const refreshed = await io.command('POST', `${tripPath}/capacity-plan`, { recordVersion: version(plan.recordVersion), manifestHash: plan.manifestHash,
pallets, reason: '保留原货预计占用,追加散板实际实收容量,仍须独立完成装车确认' }, '重核原车加货后的容量计划')
check('原车加货后容量有效', refreshed?.planValid === true && !refreshed.blockReason)
proof.status = 'BOUND_AWAITING_REVIEW'
proof.pallets = receipt.pallets
proof.addedItems = added
await context.checkpoint()
return proof
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint(); throw error
}
}
@@ -0,0 +1,75 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { prepareLooseboardPool } from './looseboard-pool-preparation.mjs'
// 模拟原车资源及容量版本;记录所有写入以验证不会创建主档或自动补发。
function fixture(fault = '', validPlan = false) {
const tripProof = { marker: 'fixture', preparation: 'VERIFIED', tripNo: 'TR-1', dispatchNo: 'DP-1', warehouseId: '42', vehicleId: '11', driverId: '12' }
const demand = { marker: 'fixture', finalStatus: 'PENDING_ACCEPTANCE' }
const trip = { ...tripProof, status: 'PLANNED', recordVersion: 1,
stops: [{ destinationKey: 'ORIGIN' }, { destinationKey: 'FBA:TEST' }] }
const plan = { planValid: validPlan, recordVersion: 1, manifestHash: 'a'.repeat(64), availableSegments: [{}],
cargo: [{ dispatchNo: 'DP-1', warehousePalletId: '71', palletNo: 'P-1', destinationKey: 'FBA:TEST' }] }
const offer = { offerNo: 'OF-1', tripNo: 'TR-1', status: 'DRAFT', recordVersion: 0, tripRecordVersion: 1 }
const calls = [], checkpoints = []; let creates = 0, publishes = 0, bindCalls = 0
const context = { marker: 'fixture', createdResources: { fleetTrips: [tripProof], phase4OverseasBusiness: { looseboard: demand } },
assert(_m, name, _r, passed) { assert.ok(passed, name) }, apiSuccess() {},
async checkpoint() { if (fault === 'disk') throw Error('disk'); checkpoints.push(structuredClone(tripProof.looseboardOffer)) },
async request(method, path, body) {
calls.push({ method, path, body })
let data
if (path.endsWith('/dispatches/DP-1')) data = { dispatchNo: 'DP-1', status: 'ACTIVE', stage: 'LOADING',
counts: { pickedPackageCount: fault === 'picked' ? 1 : 0, shippedPackageCount: 0 } }
else if (path.endsWith('/trips/TR-1')) data = { trip: { ...trip, driverId: fault === 'driver' ? '13' : '12' }, dispatches: [{ dispatchNo: 'DP-1' }] }
else if (path.endsWith('/capacity-plan')) {
if (method === 'POST') {
assert.equal(body.pallets[0].warehousePalletId, '71')
assert.equal(body.pallets[0].deliveryIndex, 1)
plan.planValid = true
}
data = structuredClone(plan)
if (fault === 'foreign cargo') data.cargo[0].dispatchNo = 'OTHER'
} else if (path.endsWith('/loading')) data = { confirmedLoad: fault === 'loaded' ? {} : null }
else if (path.endsWith('/capacity-offers') && method === 'POST') {
creates += 1
if (fault === 'network') return { http: 0 }
data = { offer: { ...offer }, replayed: creates > 1 }
if (fault === 'draft replay' && creates > 1) data.offer.offerNo = 'OF-2'
} else if (path.endsWith('/OF-1/publish')) {
publishes += 1
data = { offer: { ...offer, status: 'OPEN', capacityKnown: true, canCreateInquiry: true, canConfirm: true }, replayed: publishes > 1 }
if (fault === 'publish replay' && publishes > 1) data.offer.recordVersion = 9
} else throw Error(`Unexpected ${method} ${path}`)
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }
} }
const bind = async (ctx, args) => {
bindCalls += 1
assert.equal(ctx, context); assert.equal(args.demand, demand); assert.equal(args.tripProof, tripProof)
assert.equal(args.offerNo, 'OF-1'); assert.equal(args.destinationKey, 'FBA:TEST')
assert.equal(tripProof.looseboardOffer.status, 'OPEN')
return { status: 'CONFIRMED_ORIGINAL_ORDER', tripNo: 'TR-1', offerNo: 'OF-1' }
}
return { context, args: { demand, tripProof, destinationKey: 'FBA:TEST', unitPrice: 25 }, bind, calls, checkpoints, counts: () => ({ creates, publishes, bindCalls }) }
}
for (const valid of [false, true]) test(`prepare owned pool and pass original demand to binding; existing plan=${valid}`, async () => {
const f = fixture('', valid)
await prepareLooseboardPool(f.context, f.args, f.bind)
assert.deepEqual(f.counts(), { creates: 2, publishes: 2, bindCalls: 1 })
assert.equal(f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/capacity-plan')).length, valid ? 0 : 1)
assert.equal(f.args.tripProof.looseboardOffer.status, 'BOUND_ORIGINAL_DEMAND')
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind))
assert.equal(f.calls.some(c => /\/vehicles|\/drivers|\/iam/.test(c.path)), false)
})
for (const fault of ['picked', 'driver', 'foreign cargo', 'loaded', 'network', 'draft replay', 'publish replay', 'disk']) {
test(`pool preparation fails closed on ${fault}`, async () => {
const f = fixture(fault)
await assert.rejects(prepareLooseboardPool(f.context, f.args, f.bind))
assert.equal(f.args.tripProof.looseboardOffer.status, 'FAILED')
assert.equal(f.counts().bindCalls, 0)
if (fault === 'network') assert.equal(f.counts().creates, 1)
if (fault === 'disk') assert.equal(f.calls.length, 0)
assert.equal(f.calls.some(c => /cancel|close|delete/.test(c.path)), false)
})
}
@@ -0,0 +1,97 @@
import { testPlannedCapacity } from './fleet-booking-reservation-acceptance.mjs'
import { bindLooseboardDemand } from './looseboard-pool-binding.mjs'
const API = '/api/platform/v1/last-leg'
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'looseboard-pool-preparation', deploymentBlocker: true }
const validVersion = value => Number.isSafeInteger(value) && value >= 0
/** 复用本轮已审核且未扫码车次,发布可核验余位并衔接散板原单。 */
export async function prepareLooseboardPool(context, { demand, tripProof, destinationKey, unitPrice }, bind = bindLooseboardDemand) {
if (!context.createdResources?.fleetTrips?.includes(tripProof)
|| tripProof.preparation !== 'VERIFIED' || !tripProof.tripNo || !tripProof.dispatchNo || tripProof.looseboardOffer
|| context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| demand.finalStatus !== 'PENDING_ACCEPTANCE' || demand.poolBinding || !destinationKey
|| !Number.isFinite(unitPrice) || unitPrice <= 0 || typeof context.checkpoint !== 'function') {
throw Error('散板余位准备缺少本轮原单、已审核未占用车次、约定目的地/价格或检查点')
}
const proof = tripProof.looseboardOffer = { marker: context.marker, status: 'PREPARING', requests: [] }
const check = (name, condition) => {
context.assert(MODULE, name, null, Boolean(condition), `tripNo=${tripProof.tripNo}`, OPTIONS)
if (!condition) throw Error(name)
}
const request = async (method, path, body) => {
const log = { method, path, ...(body ? { body: structuredClone(body) } : {}), status: 'PREPARED' }
proof.requests.push(log)
await context.checkpoint()
let response
try { log.status = 'SENDING'; response = await context.request(method, path, body) }
catch (error) { log.status = 'UNKNOWN_RESULT'; throw error }
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 ? 'UNKNOWN_RESULT' : 'REJECTED'
await context.checkpoint()
context.apiSuccess(MODULE, `散板余位准备 ${method}`, response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
if (!ok) throw Error('散板余位准备失败;不得换键重造或自动重试')
return response.body.data
}
const tripPath = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
const pool = `${API}/fleet/capacity-offers`
try {
const dispatch = await request('GET', `${API}/dispatches/${encodeURIComponent(tripProof.dispatchNo)}`)
check('仅在原排车未扫码未发运时接入散板', dispatch?.dispatchNo === tripProof.dispatchNo && dispatch.status === 'ACTIVE'
&& ['BEFORE_LOADING', 'LOADING'].includes(dispatch.stage)
&& Number(dispatch.counts?.pickedPackageCount) === 0 && Number(dispatch.counts?.shippedPackageCount) === 0)
let detail = await request('GET', tripPath)
check('散板接车复用本轮同仓同车同司机', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'PLANNED'
&& String(detail.trip.warehouseId) === String(tripProof.warehouseId)
&& String(detail.trip.vehicleId) === String(tripProof.vehicleId) && String(detail.trip.driverId) === String(tripProof.driverId)
&& detail.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)
&& detail.trip.stops?.filter(stop => stop.destinationKey === destinationKey).length === 1)
let plan = await request('GET', `${tripPath}/capacity-plan`)
check('容量依据来自本轮原车真实货物', Array.isArray(plan?.cargo) && plan.cargo.length > 0
&& plan.cargo.every(row => row.dispatchNo === tripProof.dispatchNo) && validVersion(plan.recordVersion)
&& typeof plan.manifestHash === 'string' && /^[0-9a-f]{64}$/.test(plan.manifestHash))
if (plan.planValid !== true) {
const pallets = testPlannedCapacity(plan.cargo, detail.trip.stops)
proof.planningBasis = { actual: false, kind: 'CONTROLLED_TEST_PLAN', pallets }
plan = await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash,
pallets, reason: '仅本轮真实实托的受控预计占用;不得作为装车实测或回执' })
}
check('车次容量计划有效且无阻断', plan?.planValid === true && !plan.blockReason && plan.availableSegments?.length > 0)
const loading = await request('GET', `${tripPath}/loading`)
check('预计容量未冒充实载确认', loading?.confirmedLoad == null)
detail = await request('GET', tripPath)
check('发布前车次仍未发车且版本有效', detail?.trip?.status === 'PLANNED' && validVersion(detail.trip.recordVersion))
const draftBody = { tripNo: tripProof.tripNo, tripRecordVersion: detail.trip.recordVersion,
cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000',
remark: context.marker, idempotencyKey: `${context.marker}-looseboard-offer` }
const draft = await request('POST', pool, draftBody)
check('余位草稿属于原车且版本完整', draft?.offer?.tripNo === tripProof.tripNo && draft.offer.status === 'DRAFT'
&& typeof draft.offer.offerNo === 'string' && draft.offer.offerNo.length > 0
&& validVersion(draft.offer.tripRecordVersion) && validVersion(draft.offer.recordVersion))
proof.offerNo = draft.offer.offerNo
const replay = await request('POST', pool, draftBody)
check('余位发布前草稿重放不生成第二份', replay?.replayed === true && JSON.stringify(replay.offer) === JSON.stringify(draft.offer))
const publishBody = { tripRecordVersion: draft.offer.tripRecordVersion, offerRecordVersion: draft.offer.recordVersion,
idempotencyKey: `${context.marker}-looseboard-publish` }
const publishPath = `${pool}/${encodeURIComponent(proof.offerNo)}/publish`
const published = await request('POST', publishPath, publishBody)
check('原车余位已开放且可登记承运', published?.offer?.offerNo === proof.offerNo && published.offer.tripNo === tripProof.tripNo
&& published.offer.status === 'OPEN' && published.offer.capacityKnown && published.offer.canCreateInquiry && published.offer.canConfirm)
const publishedAgain = await request('POST', publishPath, publishBody)
check('余位发布重放不重复推进版本', publishedAgain?.replayed === true && JSON.stringify(publishedAgain.offer) === JSON.stringify(published.offer))
proof.status = 'OPEN'
await context.checkpoint()
const binding = await bind(context, { demand, tripProof, offerNo: proof.offerNo, destinationKey, unitPrice })
check('散板原单绑定已确认且指向原车余位', binding?.status === 'CONFIRMED_ORIGINAL_ORDER'
&& binding.tripNo === tripProof.tripNo && binding.offerNo === proof.offerNo)
proof.status = 'BOUND_ORIGINAL_DEMAND'
await context.checkpoint()
return binding
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint()
throw error
}
}
@@ -0,0 +1,89 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { receiveLooseboardPool } from './looseboard-pool-receipt.mjs'
function fixture(fault = '') {
const cargo = { destinationKey: 'FBA:TEST', packageCount: 3, unitPrice: 25 }
const tripProof = { tripNo: 'TR-1', warehouseId: '42' }
const demand = { marker: 'fixture', orderNo: 'LB-1', customerId: '101', poolBinding: {
tripNo: 'TR-1', bookingNo: 'BK-1', status: 'CONFIRMED_ORIGINAL_ORDER', createRequest: { cargo, expectedPalletCount: 2 } } }
const booking = { bookingNo: 'BK-1', tripNo: 'TR-1', customerId: '101', status: 'CONFIRMED', recordVersion: 1, cargo }
const pallets = [], calls = [], snapshots = []; let incomingWrites = 0, receiptWrites = 0, palletized = false
const source = () => ({ incomingNo: 'IN-1', warehouseId: fault === 'warehouse' ? '99' : '42', destinationKey: cargo.destinationKey,
status: palletized ? 'WAIT_CUSTOMER_CONFIRMATION' : 'WAIT_PDA_RECEIVING', receiptHash: 'hash',
receivedPackages: fault === 'quantity' ? 4 : 3,
pallets: palletized ? pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletNo: p.palletNo, packages: p.cartonCount })) : [] })
const context = { marker: 'fixture', createdResources: { phase4OverseasBusiness: { looseboard: demand }, fleetTrips: [tripProof] },
assert(_m, name, _r, ok) { assert.ok(ok, name) }, apiSuccess() {},
async checkpoint() { if (fault === 'disk') throw Error('disk'); snapshots.push(structuredClone(demand.receipt)) },
async request(method, path, body, token) {
calls.push({ method, path, body: structuredClone(body) })
let data
if (path.endsWith('/bookings')) data = { tripRecordVersion: 1, records: [booking] }
else if (path.endsWith('/incoming') && method === 'POST') {
incomingWrites += 1
if (fault === 'network') return { http: 0 }
data = { tripRecordVersion: 2, booking: { ...booking, incomingNo: 'IN-1' }, replayed: incomingWrites > 1 }
if (fault === 'replay' && incomingWrites > 1) data.booking.incomingNo = 'IN-2'
} else if (path.endsWith('/fulfillment')) data = { preview: { tripRecordVersion: 2, recordVersion: 2, incoming: source() } }
else if (path.endsWith('/pallets/drafts')) {
assert.equal(token, 'pda'); assert.equal(body.cartonCount, pallets.length ? 1 : 2)
pallets.push({ id: String(pallets.length + 1), cartonCount: body.cartonCount, status: 'DRAFT' })
data = { pallets }
} else if (path.endsWith('/generate')) {
const pallet = pallets.find(p => path.includes(`/pallets/${p.id}/`))
Object.assign(pallet, { palletNo: `P-${pallet.id}`, warehousePalletId: String(70 + Number(pallet.id)) })
data = { pallets }
} else if (path.endsWith('/bind')) data = { pallets }
else if (path.endsWith('/palletize')) {
assert.deepEqual(body.photoFileIds, ['9'])
palletized = true; pallets.forEach(p => { p.status = 'COMPLETED' })
data = { status: 'WAIT_CUSTOMER_CONFIRMATION', pallets }
} else if (path.endsWith('/receipt-confirmation')) {
receiptWrites += 1
assert.deepEqual(body.pallets, [{ warehousePalletId: '71', palletSpaces: 1 }, { warehousePalletId: '72', palletSpaces: 1 }])
assert.equal(body.unitPrice, 25)
data = { tripRecordVersion: 3, booking: { ...booking, status: 'RECEIVED', incomingNo: 'IN-1',
receipt: { source: { ...source(), status: 'READY_FOR_DISPATCH' } } }, replayed: receiptWrites > 1 }
} else throw Error(`Unexpected ${method} ${path}`)
if (path.includes('/warehouse-transfers/')) data.transferNo = fault === 'wrong incoming' ? 'OTHER' : 'IN-1'
return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }
},
async requestMultipart(method, path, file, token) {
assert.equal(token, 'pda'); assert.equal(file.type, 'image/png'); assert.ok(file.content.length > 0)
calls.push({ method, path })
return { http: 200, body: { code: '200', data: { fileId: fault === 'file' ? null : '9' } } }
} }
return { context, calls, snapshots, counts: () => ({ incomingWrites, receiptWrites }),
args: { demand, tripProof, pdaToken: 'pda', measurements: [2, 1].map(cartonCount => ({ cartonCount, lengthCm: 100, widthCm: 100,
heightCm: 90, volumeCbm: 0.9, grossWeightKg: 40 })) } }
}
test('original looseboard booking receives two pallets three boxes and confirms once with replay', async () => {
const f = fixture()
const result = await receiveLooseboardPool(f.context, f.args)
assert.equal(result.status, 'RECEIVED_READY_FOR_DISPATCH')
assert.deepEqual(result.pallets.map(p => p.packages), [2, 1])
assert.deepEqual(f.counts(), { incomingWrites: 2, receiptWrites: 2 })
assert.equal(JSON.stringify(f.snapshots).includes('pdaToken'), false)
await assert.rejects(receiveLooseboardPool(f.context, f.args))
})
for (const fault of ['network', 'replay', 'warehouse', 'quantity', 'file', 'disk', 'wrong incoming']) {
test(`receipt stops on ${fault} before operational confirmation`, async () => {
const f = fixture(fault)
await assert.rejects(receiveLooseboardPool(f.context, f.args))
assert.equal(f.args.demand.receipt.status, 'FAILED')
assert.equal(f.counts().receiptWrites, 0)
if (fault === 'network') assert.equal(f.counts().incomingWrites, 1)
if (fault === 'disk') assert.equal(f.calls.length, 0)
})
}
test('wrong batch and inconsistent measured totals are blocked before HTTP', async () => {
for (const change of [f => { f.args.demand.marker = 'other' }, f => { f.args.measurements[0].cartonCount = 3 }]) {
const f = fixture(); change(f)
await assert.rejects(receiveLooseboardPool(f.context, f.args))
assert.equal(f.calls.length, 0)
}
})
@@ -0,0 +1,108 @@
const API = '/api/platform/v1/last-leg'
const OPTIONS = { scenario: 'looseboard-pool-receipt', deploymentBlocker: true }
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
const validVersion = value => Number.isSafeInteger(value) && value >= 0
const id = value => String(value ?? '')
/** 原承运来货经PDA实收及运营确认;测量值由调用方明确给出,不借历史库存。 */
export async function receiveLooseboardPool(context, { demand, tripProof, pdaToken, measurements }) {
const binding = demand?.poolBinding, cargo = binding?.createRequest?.cargo
if (context.createdResources?.phase4OverseasBusiness?.looseboard !== demand || demand?.marker !== context.marker
|| !context.createdResources?.fleetTrips?.includes(tripProof) || binding?.tripNo !== tripProof?.tripNo
|| binding?.status !== 'CONFIRMED_ORIGINAL_ORDER' || demand.receipt || !pdaToken
|| typeof context.checkpoint !== 'function' || !Array.isArray(measurements) || !measurements.length
|| measurements.length !== binding.createRequest.expectedPalletCount
|| measurements.some(m => !Number.isSafeInteger(m.cartonCount) || m.cartonCount <= 0
|| ['lengthCm', 'widthCm', 'heightCm', 'volumeCbm', 'grossWeightKg'].some(k => !Number.isFinite(m[k]) || m[k] <= 0))
|| measurements.reduce((sum, m) => sum + m.cartonCount, 0) !== cargo?.packageCount) {
throw Error('散板实收缺少本轮原单承运、PDA身份或逐托测量与箱数依据')
}
const proof = demand.receipt = { marker: context.marker, status: 'RECEIVING', bookingNo: binding.bookingNo, requests: [], pallets: [] }
const check = (name, condition) => {
context.assert('phase4-overseas-business', name, null, Boolean(condition), `orderNo=${demand.orderNo}`, OPTIONS)
if (!condition) throw Error(name)
}
const io = async (method, path, body, token, multipart = false) => {
const log = { method, path, status: 'PREPARED', ...(body && !multipart ? { body: structuredClone(body) } : {}) }
proof.requests.push(log); await context.checkpoint()
let response
try {
log.status = 'SENDING'
response = multipart ? await context.requestMultipart(method, path, body, token) : 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 context.checkpoint()
context.apiSuccess('phase4-overseas-business', '散板原承运实收', response, JSON.stringify({ http: log.http, requestId: log.requestId, traceId: log.traceId }), OPTIONS)
if (!ok) throw Error('散板实收请求失败;未知结果不自动重发')
return response.body.data
}
const key = action => `${context.marker}-looseboard-${action}`
const pool = `${API}/fleet/capacity-offers/inquiries/${encodeURIComponent(binding.bookingNo)}`
const trip = `${API}/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
const bookingMatches = row => row?.bookingNo === binding.bookingNo && row.tripNo === tripProof.tripNo && id(row.customerId) === id(demand.customerId)
try {
const list = await io('GET', `${trip}/bookings`)
const matches = list?.records?.filter(row => row.bookingNo === binding.bookingNo) || []
const booking = matches[0]
check('来货前保持原车原客户唯一承运且未登记来货', matches.length === 1 && bookingMatches(booking)
&& booking.status === 'CONFIRMED' && !booking.incomingNo && !booking.dispatchNo
&& validVersion(list.tripRecordVersion) && validVersion(booking.recordVersion)
&& JSON.stringify(booking.cargo) === JSON.stringify(cargo))
const body = { tripRecordVersion: list.tripRecordVersion, recordVersion: booking.recordVersion,
estimatedArrivalAt: new Date(Date.now() + 300000).toISOString(), deliveryInstructions: `${context.marker} TEST自送`, idempotencyKey: key('incoming') }
const created = await io('POST', `${pool}/incoming`, body)
check('登记来货保留原承运身份', bookingMatches(created?.booking) && created.booking.status === 'CONFIRMED'
&& typeof created.booking.incomingNo === 'string' && created.booking.incomingNo.length > 0 && !created.booking.receipt)
proof.incomingNo = created.booking.incomingNo
const replay = await io('POST', `${pool}/incoming`, body)
check('来货重放没有产生第二单', replay?.replayed === true && JSON.stringify(replay.booking) === JSON.stringify(created.booking)
&& replay.tripRecordVersion === created.tripRecordVersion)
const initial = (await io('GET', `${pool}/fulfillment`))?.preview?.incoming
check('来货范围与初始实收状态正确', initial?.incomingNo === proof.incomingNo && id(initial.warehouseId) === id(tripProof.warehouseId)
&& initial.destinationKey === cargo.destinationKey && initial.status === 'WAIT_PDA_RECEIVING' && initial.pallets?.length === 0)
const pda = `/api/pda/v1/last-leg/warehouse-transfers/${encodeURIComponent(proof.incomingNo)}`
for (const [index, measurement] of measurements.entries()) {
const draft = await io('POST', `${pda}/pallets/drafts`, { ...measurement, idempotencyKey: key(`measure-${index}`) }, pdaToken)
const newPallets = draft?.pallets?.filter(row => !proof.pallets.some(p => id(p.id) === id(row.id))) || []
check('逐托测量只新增本次一个草稿', draft?.transferNo === proof.incomingNo && draft?.pallets?.length === index + 1 && newPallets.length === 1
&& newPallets[0].status === 'DRAFT' && newPallets[0].cartonCount === measurement.cartonCount
&& /^[1-9]\d*$/.test(id(newPallets[0].id)))
const palletId = newPallets[0].id, path = `${pda}/pallets/${encodeURIComponent(palletId)}`
const generated = await io('POST', `${path}/generate`, { idempotencyKey: key(`generate-${index}`) }, pdaToken)
const pallet = generated?.pallets?.find(row => id(row.id) === id(palletId))
check('本托生成真实唯一托盘资源', generated?.transferNo === proof.incomingNo && pallet?.palletNo && /^[1-9]\d*$/.test(id(pallet.warehousePalletId))
&& !proof.pallets.some(row => row.palletNo === pallet.palletNo || id(row.warehousePalletId) === id(pallet.warehousePalletId)))
await io('POST', `${path}/bind`, { palletCode: pallet.palletNo, idempotencyKey: key(`bind-${index}`) }, pdaToken)
proof.pallets.push({ id: palletId, warehousePalletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo, packages: measurement.cartonCount })
}
const uploaded = await io('POST', `${pda}/measurement-photos/files`, { name: `${context.marker}-TEST-looseboard.png`, type: 'image/png', content: PNG }, pdaToken, true)
check('实收测试图片已正式上传', /^[1-9]\d*$/.test(id(uploaded?.fileId)))
const received = await io('POST', `${pda}/palletize`, { idempotencyKey: key('palletize'), photoFileIds: [id(uploaded.fileId)] }, pdaToken)
check('本次PDA完成全部原实托', received?.transferNo === proof.incomingNo && received?.status === 'WAIT_CUSTOMER_CONFIRMATION' && received.pallets?.length === proof.pallets.length
&& proof.pallets.every(p => received.pallets.some(row => id(row.id) === id(p.id) && row.palletNo === p.palletNo
&& id(row.warehousePalletId) === p.warehousePalletId && row.cartonCount === p.packages && row.status === 'COMPLETED')))
const preview = (await io('GET', `${pool}/fulfillment`))?.preview, source = preview?.incoming
check('运营确认依据为同仓同目的地同箱数的实际实收', source?.incomingNo === proof.incomingNo && id(source.warehouseId) === id(tripProof.warehouseId)
&& source.destinationKey === cargo.destinationKey && source.status === 'WAIT_CUSTOMER_CONFIRMATION'
&& source.receiptHash && source.receivedPackages === cargo.packageCount && source.pallets?.length === proof.pallets.length
&& proof.pallets.every(p => source.pallets.some(row => id(row.warehousePalletId) === p.warehousePalletId && row.packages === p.packages))
&& validVersion(preview.tripRecordVersion) && validVersion(preview.recordVersion))
const receiptBody = { tripRecordVersion: preview.tripRecordVersion, recordVersion: preview.recordVersion, receiptHash: source.receiptHash,
pallets: proof.pallets.map(p => ({ warehousePalletId: p.warehousePalletId, palletSpaces: 1 })), unitPrice: cargo.unitPrice, idempotencyKey: key('receipt') }
const accepted = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
check('运营确认回写原承运并达到可排车', bookingMatches(accepted?.booking) && accepted.booking.status === 'RECEIVED'
&& accepted.booking.incomingNo === proof.incomingNo && accepted.booking.receipt?.source?.status === 'READY_FOR_DISPATCH')
const again = await io('POST', `${pool}/receipt-confirmation`, receiptBody)
check('实收确认重放保持原结果和版本', again?.replayed === true && JSON.stringify(again.booking) === JSON.stringify(accepted.booking)
&& again.tripRecordVersion === accepted.tripRecordVersion)
proof.status = 'RECEIVED_READY_FOR_DISPATCH'
proof.acceptedReceipt = accepted.booking.receipt
await context.checkpoint()
return proof
} catch (error) {
proof.status = 'FAILED'; proof.failure = error.message
await context.checkpoint(); throw error
}
}
@@ -74,12 +74,12 @@ test('cross-customer financial helpers stop before provisioning or writing', asy
test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
let calls = 0
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', 'pricing',
'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']) {
for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'notifications', 'pricing',
'phase4-overseas-master-data']) {
await runner.executeModule(name, async () => { calls++ })
}
assert.equal(calls, 0)
assert.equal(runner.modules.length, 9)
assert.equal(runner.modules.length, 7)
assert.ok(runner.modules.every(module => module.skipped && !module.success))
})
@@ -4,6 +4,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs'
import { verifyUsTruckFulfillment } from './us-truck-fulfillment.mjs'
test('fixed partner warehouse is reused and never retired', async () => {
const calls = []
@@ -21,6 +22,32 @@ 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')
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?')) {
receipts += 1
if (fault === 'network') return { http: 0 }
const result = structuredClone(await original(method, path, ...args))
if (fault === 'wrong customer') result.body.data.summary.customerId = 'other'
if (fault === 'wrong pallet count') result.body.data.pallets.push({ ...result.body.data.pallets[0] })
if (fault === 'replay drift' && receipts === 2) result.body.data.summary.version += 1
return result
}
return original(method, path, ...args)
}
await assert.rejects(run(context))
const proof = context.createdResources.phase4OverseasBusiness.truck
assert.equal(proof.fulfillment.status, 'FAILED')
assert.equal(proof.retained, true)
assert.equal(receipts, fault === 'replay drift' ? 2 : 1)
assert.equal(context.calls.some(c => c.path.includes('/allocate-pallets?') || c.path.includes('/TK1/status?')), false)
await assert.rejects(verifyUsTruckFulfillment(context, proof), /本轮未执行/)
})
}
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' }
@@ -63,7 +90,7 @@ function fixture(options = {}) {
}
function purchaseCustomer() { return { order: state.purchase, fees: [], progress: [], boxes: [], shipments: [], exceptionSummaries: [] } }
function purchasePlatform() { return { customerView: purchaseCustomer(), internalRemark: state.purchase?.remark ?? null, reviewHistory: state.purchaseHistories } }
function truckDetail() { return { summary: state.truck, remark: state.truck?.remark ?? null, histories: state.truckHistories } }
function truckDetail() { return structuredClone({ summary: state.truck, pallets: state.truckPallets || [], remark: state.truck?.remark ?? null, histories: state.truckHistories }) }
function looseboardDetail() { return { summary: state.looseboard, remark: state.looseboard?.remark ?? null, histories: state.looseboardHistories } }
const context = {
@@ -122,7 +149,7 @@ function fixture(options = {}) {
const hash = JSON.stringify(body)
if (state.truck) return hash === state.truckHash ? response(truckDetail()) : response(null, 'A0409')
state.truckHash = hash
state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', status: 'PENDING_REVIEW', version: 0, ...body }
state.truck = { id: '40001', orderNo: 'TK1', customerId: '10001', receivedPalletCount: 0, allocatedPalletCount: 0, status: 'PENDING_REVIEW', version: 0, ...body }
state.truckHistories.push({ fromStatus: null, toStatus: 'PENDING_REVIEW', remark: marker })
return response(truckDetail())
}
@@ -144,6 +171,32 @@ function fixture(options = {}) {
state.truckReviews.set(body.idempotencyKey, { hash, result })
return response(result)
}
const stages = { 'receive-pallets': ['WAITING_INBOUND', 'WAITING_CONSOLIDATION'],
'allocate-pallets': ['WAITING_CONSOLIDATION', 'ALLOCATED'], 'prepare-dispatch': ['ALLOCATED', 'PENDING_DISPATCH'],
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]) {
const existing = state.truckStatuses.get(body.idempotencyKey)
if (existing) return response(truckDetail())
const [from, to] = stages[action]
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 (action === 'allocate-pallets') {
assert.deepEqual(body.palletIds, ['701'])
state.truckPallets[0].status = 'ALLOCATED'
state.truck.allocatedPalletCount = 1
}
state.truck.status = to; state.truck.version += 1
state.truckHistories.push({ fromStatus: from, toStatus: to, idempotencyKey: body.idempotencyKey })
state.truckStatuses.set(body.idempotencyKey, true)
return response(truckDetail())
}
if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') {
assert.equal(body.status, 'CANCELLED')
assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true)
@@ -272,7 +325,7 @@ 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, 7)
assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 19)
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)
@@ -281,7 +334,7 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.equal(new Set(truckReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1)
assert.notEqual(truckReviews[3].body.idempotencyKey, truckReviews[0].body.idempotencyKey)
const truckStatuses = truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?'))
assert.equal(truckStatuses.length, 1)
assert.equal(truckStatuses.length, 0)
assert.ok(truckStatuses.every((call) => call.body.status === 'CANCELLED'
&& typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0))
@@ -290,20 +343,21 @@ test('procurement creation review boundaries and transport cancellation paths pa
assert.equal(looseboardCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1)
assert.equal(looseboardCalls.filter((call) => call.method === 'PUT').length, 2)
const looseboardStatuses = looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?'))
assert.equal(looseboardStatuses.length, 5)
assert.equal(looseboardStatuses.length, 4)
assert.ok(looseboardStatuses.every((call) => typeof call.body.idempotencyKey === 'string'
&& call.body.idempotencyKey.length > 0))
assert.equal(new Set(looseboardStatuses.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1)
assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 2)
assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 1)
assert.ok(looseboardStatuses.slice(3).every((call) => call.body.idempotencyKey !== looseboardStatuses[0].body.idempotencyKey))
const registry = context.createdResources.phase4OverseasBusiness
assert.equal(registry.purchase.finalStatus, 'REJECTED')
assert.equal(registry.purchase.retained, false)
assert.equal(registry.truck.finalStatus, 'CANCELLED')
assert.equal(registry.truck.retained, false)
assert.equal(registry.looseboard.finalStatus, 'CANCELLED')
assert.equal(registry.looseboard.retained, false)
assert.equal(registry.truck.finalStatus, 'COMPLETED')
assert.equal(registry.truck.retained, true)
assert.equal(registry.truck.fulfillment.status, 'HTTP_VERIFIED')
assert.equal(registry.looseboard.finalStatus, 'PENDING_ACCEPTANCE')
assert.equal(registry.looseboard.retained, true)
assert.equal(registry.partnerWarehouse.finalStatus, 'INACTIVE')
assert.equal(registry.partnerWarehouse.retained, false)
})
@@ -1,3 +1,5 @@
import { verifyUsTruckFulfillment } from './us-truck-fulfillment.mjs'
const MODULE = 'phase4-overseas-business'
const SCENARIO = 'phase4-overseas-business-write-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -426,8 +428,10 @@ async function truckFlow(context, customerId) {
&& histories.some((item) => String(item?.remark || '').includes(context.marker)),
`status=${historySummary?.status || 'missing'}, historyCount=${histories.length}; ${trace(history.response)}`, OPTIONS)
Object.assign(registry.truck, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
await verifyUsTruckFulfillment(context, registry.truck)
} finally {
await cancelMarkerOrder(context, registry.truck, base, ['PENDING_REVIEW', 'WAITING_INBOUND'], '卡车')
// 原单保留到履约完成;失败也保留现场,避免取消掩盖未知写入结果。
registry.truck.retained = true
}
}
@@ -497,11 +501,12 @@ async function looseboardFlow(context, customerId) {
records(pending.data).some(row => row.orderNo === orderNo && id(row.customerId) === customerId
&& Number(row.version) === Number(historySummary?.version)),
trace(pending.response), OPTIONS)
context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
'本模块验证原单进入需求池、禁止手工跳状态及取消;接车、实收、装车、签收需本轮独立车次生产链,未以手工改状态代替。', 'fixture')
if (!context.options?.modules?.includes('phase5-overseas-fulfillment')) context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收',
'本模块保留原单待接车;后续由 phase5-overseas-fulfillment 复用本轮原车完成实收与签收,单独执行本模块不代表全链完成。', 'fixture')
Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status })
} finally {
await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板')
// 后续用原单接入本轮车次,失败保留现场,不另建替代散板单。
registry.looseboard.retained = true
}
}
@@ -2,11 +2,38 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { verifyDiscardApproval } from './phase4-overseas-client-services.mjs'
import { verifyDiscardApproval, submitClientTransfer } from './phase4-overseas-client-services.mjs'
import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8')
for (const fault of ['', 'network', 'server', 'rejected', 'wrong order', 'wrong status', 'throws']) {
test(`transfer submit sends once and stops on uncertain result: ${fault || 'success'}`, async () => {
const calls = []
const context = { marker: 'batch', createdResources: { phase4OverseasClientServices: {} },
apiSuccess(_m, _n, response) { return response?.body?.data },
async request(method, path, body, token) {
calls.push({ method, path, body, token })
if (fault === 'throws') throw new Error('connection closed')
return { http: fault === 'network' ? 0 : fault === 'server' ? 503 : 200,
requestId: 'request', traceId: 'trace', body: { code: fault === 'rejected' ? 'A0409' : '200',
data: { transferNo: fault === 'wrong order' ? 'OTHER' : 'WT-1',
status: fault === 'wrong status' ? 'DRAFT' : 'PENDING_REVIEW' } } }
} }
if (fault) await assert.rejects(submitClientTransfer(context, 'merchant', 'WT-1'))
else assert.equal((await submitClientTransfer(context, 'merchant', 'WT-1')).status, 'PENDING_REVIEW')
assert.equal(calls.length, 1)
assert.equal(calls[0].method, 'POST')
assert.equal(calls[0].path, '/api/merchant/v1/last-leg/warehouse-transfers/WT-1/submit')
const proof = context.createdResources.phase4OverseasClientServices.transferSubmission
assert.equal(proof.status, !fault ? 'PENDING_REVIEW' : ['network', 'server', 'throws'].includes(fault)
? 'UNKNOWN_RESULT' : fault === 'rejected' ? 'REJECTED' : 'UNEXPECTED_RESULT')
assert.equal(JSON.stringify(proof).includes('merchant'), false)
await assert.rejects(submitClientTransfer(context, 'merchant', 'WT-1'), /禁止重复提交/)
assert.equal(calls.length, 1)
})
}
test('discard proof follows reserved approval and keeps actual PDA confirmation separate', () => {
assert.match(source, /Approval retains the existing reservation without deducting stock/)
assert.match(source, /Actual PDA confirmation deducts the confirmed quantity/)
@@ -70,7 +97,9 @@ 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 outbound, dispatch or delivery action is invoked/)
assert.match(source, /no dispatch or delivery action is invoked/)
assert.match(source, /await verifyCargoDiscardFulfillment\(context/)
assert.match(source, /snapshot: \(\) => discardSnapshot\(context, token, proof, proof.approvedNo\)/)
})
test('focused client services keeps the marker-owned merchant fixture when reusable credentials exist', () => {
@@ -1,4 +1,8 @@
import { verifyWarehouseTransferReview } from './warehouse-transfer-review.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'
import { verifyClientRelabelFulfillment } from './client-relabel-fulfillment.mjs'
const MODULE = 'phase4-overseas-client-services'
const SCENARIO = 'phase4-overseas-client-services-lifecycle'
@@ -51,9 +55,9 @@ async function loginMerchant(context) {
loginId: fixture.username, password: fixture.password,
}, '')
const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS)
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken),
context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS)
return nonBlank(auth?.accessToken) ? auth.accessToken : null
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
async function currentOrders(context, token) {
@@ -98,13 +102,17 @@ async function verifyRelabel(context, token, order) {
}))
const source = (Array.isArray(inventory.data) ? inventory.data : []).find((item) =>
id(item?.inventoryBalanceId) && id(item?.inboundOrderId)
&& item?.skuCode === cargo.skuCode
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0))
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0)
&& item?.skuCode === cargo.skuCode && item.inboundCode === order.row.inboundCode
&& id(item.overseasWarehouseId) === id(order.row.overseasWarehouseId)
&& item?.positions?.some?.((position) => id(position?.positionId) && Number(position?.availablePackages) > 0
&& nonBlank(position.palletNo) && position.palletNo.toUpperCase() !== 'UNBOUND'))
const position = source?.positions?.find?.((item) => id(item?.positionId) && Number(item?.availablePackages) > 0
&& nonBlank(item.palletNo) && item.palletNo.toUpperCase() !== 'UNBOUND')
context.assert(MODULE, '贴标申请使用当前客户真实库存余额、位置和来源入库单', inventory.response,
Boolean(source && position),
`balanceId=${id(source?.inventoryBalanceId) || 'missing'}, positionId=${id(position?.positionId) || 'missing'}, inboundOrderId=${id(source?.inboundOrderId) || 'missing'}`, OPTIONS)
if (!source || !position) return false
if (!await fundRelabelWallet(context, id(context.context.merchantQualificationFixture.tenantId))) return false
const body = {
customerOrderNo: order.row.orderNo,
operationWarehouseId: order.row.overseasWarehouseId,
@@ -120,9 +128,11 @@ async function verifyRelabel(context, token, order) {
productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1,
shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null,
newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker,
operationType: source.destinationFbaWarehouseCode ? 'CARTON_AND_SKU' : 'SKU_LABEL',
skuItems: [{ skuCode: cargo.skuCode, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), quantity: 1, remark: context.marker }],
relabelFileId: null, relabelFileName: null, relabelFileUrl: null,
cartonMark: `CM-${String(context.marker).slice(-32)}`,
fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`,
fbaCartonMark: source.destinationFbaWarehouseCode ? `FBA-${String(context.marker).slice(-32)}` : null,
destinationFbaWarehouseCode: source.destinationFbaWarehouseCode,
}],
}
@@ -150,9 +160,52 @@ async function verifyRelabel(context, token, order) {
&& Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_REVIEW'),
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo
const proof = context.createdResources.phase4OverseasClientServices.relabelFulfillment = {
marker: context.marker, relabelNo, customerId: id(context.context.merchantQualificationFixture.tenantId),
sourceOrderNo: order.row.orderNo, warehouseId: id(order.row.overseasWarehouseId),
sourcePositionId: id(position.positionId), sourcePalletNo: position.palletNo, status: 'PENDING_REVIEW',
}
const access = await resolvePickerAccess(context)
if (!access) throw new Error('贴标后续缺少有效PDA角色')
const picker = await createPicker(context, 94, proof.warehouseId, access)
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 })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 94)
}
return true
}
// 提交只发送一次,未知结果保留单号并停止,不能用详情回查掩盖失败。
export async function submitClientTransfer(context, token, transferNo) {
if (!nonBlank(transferNo)) throw new Error('仓储转运提交缺少单号')
if (context.createdResources.phase4OverseasClientServices.transferSubmission) throw new Error('本轮仓储转运已有提交证据,禁止重复提交')
const proof = { transferNo, marker: context.marker, status: 'SUBMITTING' }
context.createdResources.phase4OverseasClientServices.transferSubmission = proof
try {
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
Object.assign(proof, { http: response?.http, code: codeOf(response), requestId: response?.requestId, traceId: response?.traceId })
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
if (!success(response)) {
proof.status = !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
throw new Error(`仓储转运提交未成功,停止后续写入: ${transferNo}; ${trace(response)}`)
}
if (id(data?.transferNo) !== transferNo || data?.status !== 'PENDING_REVIEW') {
proof.status = 'UNEXPECTED_RESULT'
throw new Error(`仓储转运提交返回单号或状态不符: ${transferNo}`)
}
proof.status = 'PENDING_REVIEW'
return data
} catch (error) {
if (proof.status === 'SUBMITTING') proof.status = 'UNKNOWN_RESULT'
throw error
}
}
async function verifyTransfer(context, token, orders) {
const order = orders.find((value) => nonBlank(value.row?.inboundCode)
&& nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value))
@@ -184,26 +237,7 @@ async function verifyTransfer(context, token, orders) {
const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body)
const transferNo = id(created.data?.transferNo)
if (transferNo && created.data?.status === 'DRAFT') {
// The overseas gateway occasionally drops a connection while the submit
// request is still being processed. Retry transport failures only, then
// reconcile from the authoritative detail before asserting the state.
let submitted = null
for (let attempt = 1; attempt <= 3; attempt += 1) {
const response = await context.request('POST', `${TRANSFER}/${encodeURIComponent(transferNo)}/submit`, undefined, token, {})
if (response?.http === 0 && attempt < 3) {
await new Promise(resolve => setTimeout(resolve, 800 * attempt))
continue
}
const data = context.apiSuccess(MODULE, '客户提交仓储转运申请', response, trace(response), OPTIONS)
submitted = { response, data }
break
}
if (submitted?.data) created.data = submitted.data
if (created.data?.status !== 'PENDING_REVIEW') {
const reconciled = await merchant(context, token, 'GET', '提交后回查仓储转运申请',
`${TRANSFER}/${encodeURIComponent(transferNo)}`)
if (reconciled.data) created.data = reconciled.data
}
created.data = await submitClientTransfer(context, token, transferNo)
}
context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response,
Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW'
@@ -275,7 +309,21 @@ export async function verifyDiscard(context, token, markerOrders) {
&& Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'),
`items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS)
context.createdResources.phase4OverseasClientServices.discardNo = discardNo
try { await verifyDiscardApproval(context, token, body, discardNo, proof) }
try {
await verifyDiscardApproval(context, token, body, discardNo, proof)
const access = await resolvePickerAccess(context)
if (!access) throw new Error('废弃执行缺少有效PDA角色')
const picker = await createPicker(context, 93, proof.warehouseId, access)
try {
if (!picker?.token) throw new Error('废弃执行未取得本仓PDA会话')
await verifyCargoDiscardFulfillment(context, { proof, merchantToken: token, pdaToken: picker.token,
snapshot: () => discardSnapshot(context, token, proof, proof.approvedNo) })
context.addCoverageGap(MODULE, 'discard-carton-storage-evidence', '废弃底层箱、SKU和箱级流水独立取证',
'已接入PDA执行、客户完成、公共库存总账和终态重放;未执行底层独立取证', 'prerequisite', { blocking: false })
} finally {
if (picker?.membershipId) await disablePicker(context, picker, 93)
}
}
catch (error) { proof.status = 'FAILED'; proof.failure = error.message; throw error }
return true
}
@@ -363,13 +411,22 @@ export async function verifyDiscardApproval(context, token, body, discardNo, pro
}
export async function run(context) {
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, auth.accessToken)
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
context.apiSuccess(MODULE, '退出本轮客户服务会话', logout, trace(logout), OPTIONS)
}
}
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 remains pending; transfer review fixtures retained; discard rejection and approval lock checked; actual stock deduction requires separate PDA confirmation; no outbound, dispatch or delivery action is invoked',
cleanup: 'Relabel ready for dispatch; transfer review fixtures retained; discard PDA execution and customer confirmation retained; no dispatch or delivery action is invoked',
}
const token = await loginMerchant(context)
if (!token) return
for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) {
const denied = await context.request('GET', query(path, { page: 1, size: 1 }))
@@ -390,8 +447,8 @@ export async function run(context) {
const transferDone = await verifyTransfer(context, token, orders)
const discardDone = await verifyDiscard(context, token, orders)
context.createdResources.phase4OverseasClientServices.finalStatuses = {
relabel: relabelDone ? 'PENDING_REVIEW' : null,
relabel: relabelDone ? 'READY_FOR_OUTBOUND' : null,
transfer: transferDone ? 'APPROVED' : null,
discard: discardDone ? 'APPROVED' : null,
discard: discardDone ? 'COMPLETED' : null,
}
}
@@ -47,18 +47,16 @@ async function overseas(context, token, method, name, path, body = undefined) {
return { response, data: success(response) ? response.body?.data : null }
}
async function resolveWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId)
const response = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
const rows = records(response?.body?.data)
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
const warehouse = rows.find((item) => id(item?.id) === existing)
|| rows.find((item) => fixedIds.has(id(item?.id)))
|| rows[0]
export async function resolveReturnFollowupWarehouse(context) {
const existing = id(context.createdResources?.phase4OverseasReturnReceipt?.warehouseId)
if (!/^[1-9]\d*$/.test(existing)) throw new Error('缺少本轮退货实际收货仓,禁止回退其他仓库')
const response = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(existing)}?tenantId=0`)
const warehouse = response?.body?.data?.warehouse
const valid = success(response) && id(warehouse?.id) === existing
&& warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE'
add(context, '读取可执行退货签收的海外仓', response,
success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`)
return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
valid, `warehouseId=${id(warehouse?.id) || 'missing'}`)
return valid ? { id: existing, name: warehouse.warehouseName || warehouse.name } : null
}
async function loginMerchant(context) {
@@ -70,9 +68,10 @@ async function loginMerchant(context) {
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
const auth = success(response) ? response.body?.data : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
function itemMap(items) {
@@ -81,12 +80,23 @@ function itemMap(items) {
export async function run(context) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker !== context.marker || !source?.returnNo) {
if (source?.marker !== context.marker || !source?.returnNo || source?.mainChainStatus !== 'READY_FOR_FOLLOWUPS'
|| id(source?.customerId) !== id(context.context?.merchantQualificationFixture?.tenantId)) {
add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置')
return
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, source, auth.accessToken)
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
add(context, '退出本轮退货后续客户会话', logout, success(logout))
}
}
async function runWithSession(context, source, merchantToken) {
const warehouse = await resolveReturnFollowupWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
const picker = await createPicker(context, 92, warehouse.id, access)
@@ -381,8 +391,11 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re
require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0
&& task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL'))
for (const item of task.items) {
require('退货换标逐项具有真实箱唛和SKU及有效数量', nonBlank(item.cartonMark) && nonBlank(item.skuCode)
&& Number.isSafeInteger(Number(item.expectedPackages)) && Number(item.expectedPackages) > 0)
task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, {
itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version),
cartonMark: item.cartonMark, skuCode: item.skuCode,
idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`),
})
}
@@ -3,8 +3,8 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } from './phase4-overseas-return-followups.mjs'
import { normalizeModuleOrder, describeAcceptanceScope } from '../admin-test-env-acceptance.mjs'
import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound, resolveReturnFollowupWarehouse, run as runFollowups } from './phase4-overseas-return-followups.mjs'
const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url)
const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url)
@@ -177,13 +177,17 @@ test('relabelled outbound shares accounting and strict PDA scan checks', async (
test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => {
const calls = []
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] }
const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} },
addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) },
async request(method,path,body,token,_headers) {
calls.push({ method, path, body, token })
let data = task
if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
if (path.endsWith('/return-skus/complete')) {
assert.equal(body.cartonMark, 'BOX')
assert.equal(body.skuCode, 'SKU-1')
}
if (path.endsWith('/relabel-sources')) data = [{ positionId: '12', positionVersion: 0, palletCode: 'OLD', cartonMarks: ['BOX'] }]
if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' }
if (path.endsWith('/resolve')) {
@@ -215,6 +219,25 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c
assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant')
})
for (const missing of ['cartonMark', 'skuCode', 'expectedPackages']) {
test(`return relabel blocks incomplete task before quantity write: ${missing}`, async () => {
const calls = []
const item = { itemId: '1', cartonMark: 'BOX', skuCode: 'SKU-1', expectedPackages: 1, operationType: 'RETURN_BULK_SKU_LABEL' }
delete item[missing]
const context = { marker: 'batch', token: 'admin', addStep() {}, assert() {},
async request(method, path) {
calls.push(path)
const data = path.endsWith('/status')
? { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } }
: { relabelNo: 'RL-1', version: 1, items: [item] }
return { http: 200, body: { code: '200', data } }
} }
await assert.rejects(completeReturnRelabel(context, 'merchant', 'pda', 'RL-1', 'RT-1'), /真实箱唛和SKU/)
assert.equal(calls.length, 2)
assert.ok(calls.every(path => !path.endsWith('/return-skus/complete')))
})
}
test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => {
const source = await readFile(acceptanceUrl, 'utf8')
assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/)
@@ -260,12 +283,55 @@ test('HTTP acceptance uses injected clients and never bypasses APIs with databas
assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i)
})
test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => {
test('receipt-only scope remains focused while default full plan includes ordered return followups', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/)
const ordered = normalizeModuleOrder(['phase4-overseas-return-followups'])
assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups'))
assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups'))
const defaults = describeAcceptanceScope().defaultModules
assert.ok(defaults.includes('phase4-overseas-return-followups'))
assert.ok(defaults.indexOf('phase4-overseas-return-receipt') < defaults.indexOf('phase4-overseas-return-followups'))
})
test('return followups read only the actual receipt warehouse and reject drift', async () => {
for (const patch of [{}, { id: '202' }, { status: 'INACTIVE' }, { warehouseType: 'DOMESTIC' }]) {
const calls = [], checks = []
const context = { createdResources: { phase4OverseasReturnReceipt: { warehouseId: '201' } },
addStep(module, name, response, passed) { checks.push(passed) },
async request(method, path) {
calls.push({ method, path })
return { http: 200, body: { code: '200', data: { warehouse: { id: '201', status: 'ACTIVE', warehouseType: 'OVERSEAS', ...patch } } } }
} }
const warehouse = await resolveReturnFollowupWarehouse(context)
assert.equal(Boolean(warehouse), Object.keys(patch).length === 0)
assert.equal(calls.length, 1)
assert.equal(calls[0].path, '/api/platform/v1/warehouse-admin/201?tenantId=0')
}
await assert.rejects(resolveReturnFollowupWarehouse({ createdResources: {} }), /实际收货仓/)
})
test('return followups stop before login when main chain failed or customer changed', async () => {
for (const patch of [{ mainChainStatus: 'FAILED' }, { customerId: '102' }, { marker: 'OLD' }]) {
const checks = []
await runFollowups({ marker: 'CURRENT', context: { merchantQualificationFixture: { tenantId: '101' } },
createdResources: { phase4OverseasReturnReceipt: { marker: 'CURRENT', returnNo: 'TH202610220001',
mainChainStatus: 'READY_FOR_FOLLOWUPS', customerId: '101', ...patch } },
addStep(module, name, response, passed) { checks.push(passed) },
request() { throw new Error('must not send HTTP') } })
assert.deepEqual(checks, [false])
}
})
test('fixed return main chain excludes customer provisioning and write transport retries', async () => {
const source = await readFile(moduleUrl, 'utf8')
assert.match(source, /context.options.returnMainChainOnly \|\| context.options.fixedCustomerId/)
assert.match(source, /addCoverageGap\(MODULE, 'return-isolation-boundaries'/)
assert.match(source, /method === 'GET' && attempt < 3/)
assert.match(source, /mainChainStatus = context.steps.slice\(start\).every/)
for (const url of [moduleUrl, new URL('./phase4-overseas-return-followups.mjs', import.meta.url)]) {
assert.match(await readFile(url, 'utf8'), /finally \{\s*const logout = await context.request\('POST', '\/api\/auth\/logout'/)
}
})
test('return followups use SKU quantities, explicit FBA destination and current file and candidate contracts', async () => {
@@ -56,7 +56,7 @@ function add(context, name, response, passed, evidence = '') {
async function overseas(context, token, method, name, path, body = undefined) {
let response = await context.request(method, path, body, token, {})
for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) {
for (let attempt = 1; method === 'GET' && attempt < 3 && response?.http === 0; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
response = await context.request(method, path, body, token, {})
}
@@ -87,9 +87,10 @@ async function loginMerchant(context) {
const response = await context.request('POST', '/api/auth/login', {
loginId: fixture.username, password: fixture.password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`)
return nonBlank(token) ? token : null
const auth = success(response) ? response.body?.data : null
add(context, '客户登录并取得海外访问令牌', response, nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken),
`tokenPresent=${nonBlank(auth?.accessToken)}, refreshPresent=${nonBlank(auth?.refreshToken)}`)
return nonBlank(auth?.accessToken) && nonBlank(auth?.refreshToken) ? auth : null
}
function itemMap(items) {
@@ -123,14 +124,38 @@ export async function approveReturnFixture(context, merchantToken, created) {
}
export async function run(context) {
const start = context.steps.length
const auth = await loginMerchant(context)
if (!auth) return
try {
return await runWithSession(context, auth.accessToken)
} catch (error) {
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker === context.marker) source.mainChainStatus = 'FAILED'
throw error
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
add(context, '退出本轮退货客户会话', logout, success(logout))
const source = context.createdResources.phase4OverseasReturnReceipt
if (source?.marker === context.marker && context.steps.slice(start).some(step => !step.passed)) {
source.mainChainStatus = 'FAILED'
}
}
}
async function runWithSession(context, merchantToken) {
const start = context.steps.length
context.createdResources.phase4OverseasReturnReceipt = {
marker: context.marker, returnNo: null, receiptNos: [], receiptBatchNos: [], cartonMarks: [],
billingExpectation: 'One PENDING_PRICING storage billing batch per successful non-replayed receipt',
}
const merchantToken = await loginMerchant(context)
const warehouse = await resolveWarehouse(context)
const access = await resolvePickerAccess(context)
if (!merchantToken || !warehouse || !access) return
Object.assign(context.createdResources.phase4OverseasReturnReceipt, {
warehouseId: warehouse.id, customerId: id(context.context?.merchantQualificationFixture?.tenantId),
mainChainStatus: 'RUNNING',
})
const picker = await createPicker(context, 91, warehouse.id, access)
if (!picker?.token) {
add(context, '创建退货签收PDA操作员', null, false, '未取得绑定仓PDA令牌')
@@ -477,11 +502,16 @@ export async function run(context) {
trace(merchantSummary.response), OPTIONS)
}
if (context.options.returnMainChainOnly) {
if (context.options.returnMainChainOnly || context.options.fixedCustomerId) {
context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios';
context.addCoverageGap(MODULE, 'return-isolation-boundaries', '跨客户、跨仓及并发退货扩展',
'主链复用固定客户;跨客户扩展仍需独立固定资源,不创建第二客户,也不计边界通过',
'prerequisite', { blocking: false })
} else {
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
}
context.createdResources.phase4OverseasReturnReceipt.mainChainStatus = context.steps.slice(start).every(step => step.passed)
? 'READY_FOR_FOLLOWUPS' : 'FAILED'
} finally {
await disablePicker(context, picker, 91)
}
@@ -1,12 +1,87 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { beijingDate, matchesProcurementNo } from './phase4-overseas-wallet-and-pallet-cost.mjs'
import { beijingDate, matchesProcurementNo, runFixedCustomerProcurement, run } from './phase4-overseas-wallet-and-pallet-cost.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const source = readFileSync(new URL('./phase4-overseas-wallet-and-pallet-cost.mjs', import.meta.url), 'utf8')
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), 'utf8')
function fixedHarness({ warehouse = true, profileId = '101', logoutCode = '200' } = {}) {
const context = new AcceptanceRun({ environment: 'test', fixedCustomerId: 'TEN101', quiet: true })
context.marker = 'PROC-FIXED-TEST'
context.context.merchantQualificationFixture = { marker: context.marker, tenantId: '101',
tenantName: 'QA', reused: true, username: 'fixture-user', password: 'fixture-password' }
const calls = []
context.request = async (method, path) => {
calls.push({ method, path })
let data, code = '200'
if (method === 'GET' && path.endsWith('/warehouse-options')) data = warehouse ? [{ warehouseId: '201' }] : []
else if (method === 'POST' && path === '/api/auth/login') data = { accessToken: 'test-token', refreshToken: 'test-refresh' }
else if (method === 'GET' && path === '/api/merchant/v1/iam/tenant/profile') data = { id: profileId, status: 'ACTIVE' }
else if (method === 'POST' && path === '/api/auth/logout') code = logoutCode
else throw new Error(`unexpected request ${method} ${path}`)
return { http: 200, body: { code, data } }
}
return { context, calls }
}
test('固定客户采购进入默认模块,仅复用候选仓与会话,不初始化钱包', async () => {
const { context, calls } = fixedHarness()
let invoked = 0
await context.executeModule('phase4-overseas-wallet-and-pallet-cost', () => runFixedCustomerProcurement(context,
async (actualContext, fixture, token, warehouseId) => {
invoked++
assert.equal(actualContext, context)
assert.equal(fixture.tenantId, '101')
assert.equal(token, 'test-token')
assert.equal(warehouseId, '201')
}))
assert.equal(invoked, 1)
assert.equal(context.modules[0].skipped, undefined)
assert.equal(context.modules[0].success, true)
assert.equal(context.coverageGaps.length, 1)
assert.equal(calls.at(-1).path, '/api/auth/logout')
assert.match(source, /if \(context.options\?\.fixedCustomerId\) return runFixedCustomerProcurement\(context\)/)
})
test('错误客户及旧批次资格在任何网络请求前阻断默认入口', async () => {
for (const patch of [{ tenantId: '102' }, { marker: 'OLD' }, { reused: false }]) {
const { context, calls } = fixedHarness()
Object.assign(context.context.merchantQualificationFixture, patch)
await assert.rejects(run(context), /固定客户资格/)
assert.equal(calls.length, 0)
}
})
test('缺少候选仓不建仓;登录身份漂移不执行采购且退出会话', async () => {
for (const options of [{ warehouse: false }, { profileId: '102' }]) {
const { context, calls } = fixedHarness(options)
let invoked = false
await assert.rejects(runFixedCustomerProcurement(context, async () => { invoked = true }), /合作仓|身份不匹配/)
assert.equal(invoked, false)
if (options.profileId) assert.equal(calls.at(-1).path, '/api/auth/logout')
else assert.equal(calls.length, 1)
}
})
test('采购执行失败仍退出会话,退出失败保留失败断言', async () => {
const { context, calls } = fixedHarness()
await assert.rejects(runFixedCustomerProcurement(context, async () => { throw new Error('unknown result') }), /unknown result/)
assert.equal(calls.at(-1).path, '/api/auth/logout')
const failedLogout = fixedHarness({ logoutCode: 'A0409' })
await runFixedCustomerProcurement(failedLogout.context, async () => {})
assert.ok(failedLogout.context.steps.some(step => !step.passed))
})
test('已存在采购证据不重复创建样本', async () => {
const { context, calls } = fixedHarness()
context.createdResources.procurementSample = { status: 'FAILED' }
await assert.rejects(runFixedCustomerProcurement(context), /不自动重跑/)
assert.equal(calls.length, 0)
})
test('采购单号按北京时间日期执行精确合同', () => {
const instant = new Date('2026-09-05T16:30:00.000Z')
assert.equal(beijingDate(instant), '20260906')
@@ -1,5 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { verifyProcurementSample } from './procurement-sample-fulfillment.mjs'
import { normalizeFixedCustomerId } from './merchant-qualification-fixture.mjs'
const MODULE = 'phase4-overseas-wallet-and-pallet-cost'
const SCENARIO = 'phase4-overseas-wallet-and-pallet-cost-http'
@@ -314,7 +315,41 @@ async function verifyDestuffCosts(context, completedFacts) {
return evidence
}
/** 固定客户只执行采购正常链,不初始化余额、授信或修改公共资源。 */
export async function runFixedCustomerProcurement(context, fulfill = procurementFlow) {
const fixture = context.context?.merchantQualificationFixture
const customerId = normalizeFixedCustomerId(context.options?.fixedCustomerId)
if (fixture?.marker !== context.marker || id(fixture?.tenantId) !== customerId
|| fixture?.reused !== true || !fixture?.username || !fixture?.password) {
throw new Error('采购正向链缺少本轮固定客户资格,禁止创建或借用其他客户')
}
if (context.createdResources.procurementSample) throw new Error('已有采购验收证据,不自动重跑或覆盖')
context.addCoverageGap(MODULE, 'fixed-wallet-lifecycle', '授信充值、贴标资金及拆柜成本综合场景',
'本分支仅执行既有余额上的采购正向履约;未执行钱包初始化及原综合模块其他场景',
'not-implemented', { blocking: false })
const warehousesResponse = await context.request('GET', `${PROCUREMENT}/warehouse-options`)
const candidates = success(warehousesResponse) ? warehousesResponse.body.data : null
const warehouse = Array.isArray(candidates) ? candidates.find(row => /^[1-9]\d*$/.test(id(row?.warehouseId))) : null
assert(context, '采购正向链复用正式候选收货仓', warehousesResponse, Boolean(warehouse))
if (!warehouse) throw new Error('缺少现有采购合作仓,不自动创建')
const auth = await merchantLogin(context, fixture)
if (!auth?.accessToken || !auth?.refreshToken) throw new Error('采购客户会话或退出凭据缺失')
try {
const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken)
const profile = success(profileResponse) ? profileResponse.body.data : null
const matched = id(profile?.id) === customerId && profile?.status === 'ACTIVE'
assert(context, '采购当前会话属于指定固定客户', profileResponse, matched)
if (!matched) throw new Error('采购客户会话身份不匹配,停止业务写入')
// 共享助手在创建订单前核验现金可用余额至少3元,并逐请求持久化;不足时直接失败。
return await fulfill(context, fixture, auth.accessToken, id(warehouse.warehouseId))
} finally {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: auth.refreshToken }, auth.accessToken)
assert(context, '退出本轮固定采购客户会话', logout, success(logout))
}
}
export async function run(context) {
if (context.options?.fixedCustomerId) return runFixedCustomerProcurement(context)
const start = context.steps.length
const fixture = context.context?.merchantQualificationFixture
const completedFacts = context.createdResources?.phase4OverseasPda?.completedContainerFacts || []
@@ -53,15 +53,24 @@ test('native route conserves source keys and destinations while same destination
test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => {
assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/)
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/)
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch', 'phase4-overseas-business'\]/)
const modules = parseArguments(['--modules', 'phase5-overseas-fulfillment']).modules
for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-loading-finalize',
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch']) {
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch', 'phase4-overseas-business']) {
assert.ok(modules.indexOf(prerequisite) >= 0
&& modules.indexOf(prerequisite) < modules.indexOf('phase5-overseas-fulfillment'), prerequisite)
}
})
test('default looseboard attaches before stock snapshots and verifies its original order after signature', () => {
const start = source.indexOf('export async function run(context)')
const run = source.slice(start)
assert.ok(run.indexOf('await attachLooseboardToTrip(') < run.indexOf('const sourceTask ='))
assert.ok(run.indexOf('await verifyLooseboardDelivered(') > run.indexOf('const completedDeliveries ='))
assert.match(source, /looseboard: context.createdResources.phase4OverseasBusiness\?\.looseboard/)
assert.match(run, /sourceKeys: \[\.\.\.source.dispatch.sourceKeys, \.\.\.looseboard.proof.sourceKeys\]/)
})
test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => {
for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'value?.documents?.deliveries', 'value?.documents?.outbounds',
@@ -2,6 +2,7 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overs
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
import { attachLooseboardToTrip, verifyLooseboardDelivered } from './looseboard-fulfillment.mjs'
import { mkdir, writeFile, rename, readFile } from 'node:fs/promises'
import { resolve, dirname } from 'node:path'
import { createHash } from 'node:crypto'
@@ -45,6 +46,7 @@ export async function fulfillmentEvidenceContext(context) {
await mkdir(dirname(path), { recursive: true })
await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence,
fulfillment: context.createdResources.phase5OverseasFulfillment,
looseboard: context.createdResources.phase4OverseasBusiness?.looseboard,
fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8')
await rename(`${path}.tmp`, path)
}
@@ -1058,7 +1060,8 @@ export async function run(context) {
if (!merchantToken) return
context = await fulfillmentEvidenceContext(context)
const beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
let beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
let fulfillmentDispatch = source.dispatch
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = {
sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes,
@@ -1079,6 +1082,12 @@ export async function run(context) {
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
return
}
const looseboard = await attachLooseboardToTrip(context, tripProof, picker.token)
fulfillmentDispatch = { ...source.dispatch, sourceKeys: [...source.dispatch.sourceKeys, ...looseboard.proof.sourceKeys] }
beforeEdit = { ...beforeEdit, data: looseboard.detail }
if (!dispatchDestinationConservation(beforeEdit.data, fulfillmentDispatch)) throw new Error('散板加货后原车来源或目的地不守恒')
Object.assign(routeProof, { sourceKeys: fulfillmentDispatch.sourceKeys, beforeEdit: beforeEdit.data,
looseboardIncomingNo: looseboard.proof.incomingNo })
const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return
@@ -1118,8 +1127,8 @@ export async function run(context) {
deliveries = transferred.deliveries; outbounds = transferred.outbounds
routeProof.deliveries = deliveries
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries), trace(transferred.response), OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,fulfillmentDispatch,deliveries)) return
const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo)
if (!ready) return
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
@@ -1192,6 +1201,7 @@ export async function run(context) {
success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS)
if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return
context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data
await verifyLooseboardDelivered(context, tripProof, deliveries)
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
removedComplete?.http === 404 || removedComplete?.http === 405
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { run } from './suppliers.mjs'
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8')
const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8')
const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8')
@@ -18,3 +20,57 @@ test('supplier OpenAPI exposes the internal active category validation contract
assert.doesNotMatch(openapi,/\bscope\b/)
assert.doesNotMatch(openapi,/platform:supplier:view/)
})
function existingHarness({ empty = false, wrongDetail = false, failStats = false } = {}) {
const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true })
const calls = []
const categories = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
runner.request = async (method, path) => {
calls.push({ method, path })
assert.equal(method, 'GET', 'fixed suppliers must never mutate master data')
const url = new URL(path, 'http://offline.invalid')
let data
let code = '200'
if (url.searchParams.get('tenantId') === '1') code = 'A0400'
else if (url.pathname.endsWith('/stats')) {
data = { booking: 1, customs: 1, trucking: 1, certificate: 1 }
if (failStats) code = 'S0500'
} else if (url.searchParams.has('category')) {
const category = url.searchParams.get('category')
data = { records: empty ? [] : [{ id: String(categories.indexOf(category) + 1), category, status: 'ACTIVE' }],
page: 1, size: 20, total: empty ? 0 : 1, pages: empty ? 0 : 1 }
} else {
const id = url.pathname.split('/').at(-1)
data = { id, tenantId: wrongDetail ? '88' : '0', category: categories[Number(id) - 1], status: 'ACTIVE' }
}
return { http: 200, body: { code, data }, requestId: 'request', traceId: 'trace' }
}
return { runner, calls }
}
test('fixed supplier module runs through orchestrator using only existing read-only records', async () => {
const { runner, calls } = existingHarness()
await runner.executeModule('suppliers', () => run(runner))
assert.equal(calls.length, 10)
assert.equal(runner.modules[0].skipped, undefined)
assert.equal(runner.modules[0].success, true)
assert.equal(runner.coverageGaps.length, 1)
assert.equal(runner.coverageGaps[0].scenario, 'fixed-resource-lifecycle')
})
test('empty supplier lists retain detail gaps and never create replacements', async () => {
const { runner, calls } = existingHarness({ empty: true })
await run(runner)
assert.equal(calls.length, 6)
assert.equal(runner.coverageGaps.length, 5)
assert.ok(runner.steps.every(step => step.passed))
})
test('supplier detail scope drift and failed statistics remain failures', async () => {
for (const options of [{ wrongDetail: true }, { failStats: true }]) {
const { runner } = existingHarness(options)
await runner.executeModule('suppliers', () => run(runner))
assert.equal(runner.modules[0].success, false)
assert.ok(runner.steps.some(step => !step.passed))
}
})
@@ -16,6 +16,7 @@ function query(values = {}) { const p = new URLSearchParams(); for (const [k,v]
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
export async function run(context) {
if (context.options?.fixedCustomerId) return runExistingSuppliers(context)
const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20)
const listResponse=await context.request('GET',query({tenantId:0,category:'BOOKING',page:1,size:20}))
const list=context.apiSuccess(MODULE,'列表分页与订舱筛选',listResponse,'category=BOOKING',OPTIONS)
@@ -67,3 +68,40 @@ export async function run(context) {
context.createdResources.suppliers={marker:context.marker,supplierId,finalStatus:inactive?'INACTIVE':'UNKNOWN',retained:true}
}
}
/** 固定资源模式只读现有主档,生命周期缺口仍单独保留。 */
async function runExistingSuppliers(context) {
context.addCoverageGap(MODULE, 'fixed-resource-lifecycle', '供应商新增、编辑、停用生命周期',
'固定资源模式仅验收现有供应商列表、筛选、统计、详情和作用域拒绝,不修改公共主档',
'not-implemented', { blocking: false })
const statsResponse = await context.request('GET', `${BASE}/stats?tenantId=0`)
const stats = context.apiSuccess(MODULE, '查询四类供应商统计', statsResponse, '', OPTIONS)
const nonNegativeInteger = value => value !== null && value !== undefined && value !== ''
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0
context.assert(MODULE, '统计字段完整', statsResponse,
['booking', 'customs', 'trucking', 'certificate'].every(key => nonNegativeInteger(stats?.[key])), '', OPTIONS)
for (const category of ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']) {
const response = await context.request('GET', query({ tenantId: 0, category, status: 'ACTIVE', page: 1, size: 20 }))
const page = context.apiSuccess(MODULE, `查询已有${category}供应商`, response, '', OPTIONS)
const rows = records(page)
context.assert(MODULE, `${category}分页和启用类别筛选正确`, response,
Array.isArray(page?.records) && Number(page.page) === 1 && Number(page.size) === 20
&& nonNegativeInteger(page.total) && Number(page.pages) === Math.ceil(Number(page.total) / 20)
&& rows.length === Math.min(20, Number(page.total))
&& rows.every(row => id(row.id) && row.category === category && row.status === 'ACTIVE'), '', OPTIONS)
if (!rows.length) {
context.addCoverageGap(MODULE, `existing-${category}`, `${category}供应商详情`,
'没有启用的既有样本;未创建供应商,详情未验收', 'prerequisite', { blocking: false })
continue
}
const supplierId = id(rows[0].id)
if (!supplierId) continue
const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail = context.apiSuccess(MODULE, `回查已有${category}供应商详情`, detailResponse, '', OPTIONS)
context.assert(MODULE, `${category}详情身份、类别及平台归属一致`, detailResponse,
id(detail?.id) === supplierId && id(detail?.tenantId) === '0' && detail?.category === category
&& detail?.status === 'ACTIVE' && !Object.hasOwn(detail || {}, 'scope'), '', OPTIONS)
}
const denied = await context.request('GET', query({ tenantId: 1, page: 1, size: 20 }))
context.assert(MODULE, 'tenantId非0被拒绝', denied, code(denied, 'A0400') && traced(denied), '', OPTIONS)
}
@@ -0,0 +1,76 @@
const MODULE = 'phase4-overseas-business'
const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true }
/** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */
export async function verifyUsTruckFulfillment(context, proof) {
if (!proof || proof.marker !== context.marker || !proof.orderNo || !proof.customerId
|| context.createdResources?.phase4OverseasBusiness?.truck !== proof || proof.fulfillment
|| proof.finalStatus !== 'WAITING_INBOUND') throw Error('卡车履约必须使用本轮未执行的待入仓原单')
const evidence = proof.fulfillment = { status: 'RUNNING', requests: [] }
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' }
evidence.requests.push(log)
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'
context.apiSuccess(MODULE, `本土卡车${method} ${action || '详情'}`, response,
`HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, OPTIONS)
if (!ok) throw Error(`卡车履约请求失败,不自动重发: ${action}`)
return response.body.data
}
const identity = detail => check('卡车原单客户和版本有效', detail?.summary?.orderNo === proof.orderNo
&& String(detail.summary.customerId) === String(proof.customerId)
&& Number.isSafeInteger(detail.summary.version) && detail.summary.version >= 0)
try {
let current = await request('GET', '')
identity(current)
check('卡车实收前仍为本轮待入仓单', current.summary.status === 'WAITING_INBOUND'
&& current.remark?.startsWith(context.marker) && Number(current.summary.receivedPalletCount) === 0)
const packages = Number(current.summary.packageCount)
check('卡车实收箱数来自原订单且为有效正整数', Number.isSafeInteger(packages) && packages > 0)
const stages = [
['receive-pallets', 'WAITING_CONSOLIDATION'], ['allocate-pallets', 'ALLOCATED'],
['prepare-dispatch', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'],
['start-transit', 'IN_TRANSIT'], ['complete', 'COMPLETED'],
]
let palletId, palletNo
for (const [action, state] of stages) {
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]
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.histories?.some(row => row.idempotencyKey === body.idempotencyKey
&& row.toStatus === state && row.fromStatus === current.summary.status))
const replay = await request('POST', `/${action}`, body)
check('卡车同键重放不重复收托或推进版本', JSON.stringify(replay) === JSON.stringify(next))
current = await request('GET', '')
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 })
} catch (error) {
for (const log of evidence.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
evidence.status = 'FAILED'
evidence.failure = error.message
throw error
}
}