优化
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。
|
||||
|
||||
固定托盘造数中,`inbound-measurement.mjs` 缺少 `TP-001` 或 `YW-TP-002` 时记录阻断并停止,不自动选择其他托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
|
||||
|
||||
先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。
|
||||
|
||||
`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。
|
||||
`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;装车完成自动生成派送并发车,完整正常链后核对逐客户目的仓、实际费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。
|
||||
|
||||
其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。
|
||||
|
||||
@@ -20,7 +24,7 @@
|
||||
|
||||
`dispatch-repair-acceptance.mjs` 是本批排车修复的内部增量验收助手,不是独立 CLI。`verifyDispatchRepair` 默认只返回计划,显式执行要求 TEST、Lastleg/Admin 健康及 Schema 就绪、请求前后持久化检查点。`context.producerEvidence` 指定本轮生产回执 JSON 的路径及 SHA-256;文件须含 `environment=TEST`、`marker`、已部署 Lastleg 的 `sourceCommit` 和 `requests`。请求条目保留实际 `method/path/body/actor`、`status=RESPONDED`、`http/code/requestId/traceId/result`,不得保存登录凭据或 Token。助手从正式创建响应及父来源回执提取业务号、客户和仓库,形成资源白名单;所有 fixture 关联身份在首个请求前核对,不能仅声明“本轮新建”。缺样本标记 NOT_RUN,不借旧业务样本。
|
||||
|
||||
该助手核对取消出库/取消排车/释放重排、实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。只对已授权的新排车调用 DELETE 取消状态,不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。
|
||||
该助手将已撤销的装车后取消/人工重排场景标为 NOT_APPLICABLE,仍核对实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。
|
||||
|
||||
- 人工状态矩阵的 `FEE_CONFIRMING` 使用独立新订单,在测量提交成功后暂停审核,经正式 `fees/calculate` 生成费用;回查同一费用版本、入库客户身份、测量仍待审核及入仓码精确筛选结果。该状态是必验项,不再列入不可造数缺口;不发送费用、不推进该样本、不借历史数据。入口仍为 `--modules manual-state-matrix`,对应 `order-inbound-status-fixtures-contract.test.mjs` 和 `manual-state-matrix-contract.test.mjs`。
|
||||
- `WAIT_PRICING` 另建保留原始 `APPROVED` 的订单,先形成未发布的 `FEE_CONFIRMING`,再通过客户实时渠道目录与报价选择同仓、同目的仓、同税制的不同渠道,以正式申报更新触发费用失效;回查 `WAIT_PRICING/UNPRICED`、新渠道及精确筛选。缺少可计价替换渠道、客户漂移或状态未收敛均失败;不写业务SQL,退出专用目录查询会话。该状态同样为必验项,原先借历史 `WAIT_SPLIT` 样本的兜底已移除。
|
||||
@@ -44,7 +48,7 @@
|
||||
|
||||
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
|
||||
|
||||
`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。
|
||||
`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量确认装车,在同一次命令自动生成客户目的地单据并发车;按详情 documents 核对真实单据,原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。
|
||||
|
||||
`TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。
|
||||
|
||||
@@ -60,7 +64,7 @@
|
||||
|
||||
车辆专项入口为 `../fleet-test-env-acceptance.mjs`,先使用原 `phase4-overseas-dispatch` 依赖链创建本轮客户、头程费用和海外库存,再执行专用车辆编排;不改变已通过的 F40 默认调用顺序。默认 `--plan` 无网络,实际运行须显式 `--execute --environment TEST --run-id <新批次>`。仅串行运行,会创建当前批次资源及执行正式未执行单据取消重排,不执行部署、配置修改或业务 SQL 写入;原收货取证沿用现有只读读取器。失败保留原样本、请求与报告,不自动重造或续跑。
|
||||
|
||||
四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 用独立排车验证全覆盖及多原入仓的部分覆盖,核对原费用版本、目的地、快照及重放不重复收费;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。
|
||||
四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 复用两组独立排车样本,核对装车完成自动生成单据的客户、目的地、箱数和实际费用;已撤销的人工费用覆盖选择不计作通过;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。
|
||||
|
||||
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
|
||||
|
||||
@@ -490,6 +494,6 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采
|
||||
- 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。
|
||||
- `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。
|
||||
- `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。
|
||||
- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。
|
||||
- 合同数量与失败清单以同一源码快照的实际报告为准,不在模块索引维护固定失败数量;当前已验/未验范围统一查外层部署契约,历史报告不覆盖最新源码。
|
||||
|
||||
采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
|
||||
|
||||
@@ -63,7 +63,7 @@ function producerReport() {
|
||||
event('GET', '/api/platform/v1/last-leg/return-inventory/cartons/BOX', undefined, { cartonMark: 'BOX', sourceReturnNo: 'RT', customerId: 'C', overseasWarehouseId: 'W', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] })
|
||||
event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', overseasWarehouseId: 'W' } }, 'MERCHANT')
|
||||
dispatch('NDP', [candidate(null, '601', null, { sourceStage: 'RELABEL', orderRefs: 'RL-N', returnCartonSource: true })], 'RL-N', 1)
|
||||
event('POST', '/api/platform/v1/last-leg/dispatches/NDP/transfer-delivery', {}, { outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }] })
|
||||
event('GET', '/api/platform/v1/last-leg/dispatches/NDP', undefined, { stage:'DELIVERING', documents:{outbounds:[{outboundNo:'O',customerId:'C'}],deliveries:[{deliveryNo:'D',outboundNo:'O',customerId:'C'}]} })
|
||||
event('GET', '/api/platform/v1/last-leg/delivery-orders/D/return-inbound-options?customerId=C', undefined, [{ customerId: 'C', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', returnSources: [{ returnCartonId: '81', cartonMark: 'BOX', inventoryPositionId: null }] }])
|
||||
return { environment: 'TEST', marker: 'CURRENT', sourceCommit: commit, requests }
|
||||
}
|
||||
@@ -98,7 +98,7 @@ test('import/plan is zero-network and missing current fixtures never become a pa
|
||||
const ctx = context(); assert.equal((await verifyDispatchRepair(ctx, {})).status, 'PLAN_ONLY'); assert.equal(ctx.calls.length, 0)
|
||||
const result = await execute(ctx, {})
|
||||
assert.equal(result.status, 'PARTIAL_NOT_ALL_VERIFIED')
|
||||
assert.equal(Object.values(result.cases).every(row => row.status === 'NOT_RUN'), true)
|
||||
assert.equal(Object.entries(result.cases).filter(([key])=>key!=='cancellation').every(([,row]) => row.status === 'NOT_RUN'), true)
|
||||
assert.equal(result.sourceEventsVerified, false)
|
||||
})
|
||||
for (const field of ['lastlegHealthy', 'adminHealthy', 'schemaReady']) test(`unready ${field} blocks before any HTTP`, async () => {
|
||||
@@ -137,25 +137,7 @@ test('failed pre-send checkpoint leaves the command unsent', async () => {
|
||||
assert.equal(ctx.calls.length, 0)
|
||||
})
|
||||
|
||||
test('new unpicked dispatch cancels outbound, retains cancelled history, re-arranges released pallets and replays old transfer', async () => {
|
||||
let cancelled = false, dispatchCancelled = false, transfers = 0
|
||||
const ctx = context((method, path, body) => {
|
||||
if (path.endsWith('/transfer-delivery')) return ok({ outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }], replayed: transfers++ > 0 })
|
||||
if (path.includes('/delivery-orders/') && path.includes('/status')) { assert.ok(['DELIVERING', 'REJECTED', 'CANCELLED'].includes(body.status)); assert.equal(body.expectedVersion, 0); return reject() }
|
||||
if (path.includes('/delivery-orders/')) return ok({ order: { deliveryNo: 'D', outboundNo: 'O', status: cancelled ? 'CANCELLED' : 'WAIT_DELIVERY', version: 0, packageCount: 1 } })
|
||||
if (path.endsWith('/outbound-orders/O/cancel')) { cancelled = true; return ok(null) }
|
||||
if (path.endsWith('/outbound-orders/O')) return ok({ outbound: { status: cancelled ? 'CANCELLED' : 'WAIT_OUTBOUND' } })
|
||||
if (method === 'DELETE') { assert.equal(path, '/api/platform/v1/last-leg/dispatches/DP'); assert.equal(cancelled, true); dispatchCancelled = true; return ok(null) }
|
||||
if (path.endsWith('/dispatches/DP')) return ok({ status: dispatchCancelled ? 'CANCELLED' : 'PENDING_DEPARTURE', createdAt: '2026-10-18T16:00:00', actions: { canTransfer: true } })
|
||||
if (path.includes('/dispatches/candidates?')) return ok([{ planPalletId: '11' }])
|
||||
if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, ['11']); assert.equal(dispatchCancelled, true); return ok({ dispatchNo: 'DP-NEW', status: 'PENDING_DEPARTURE', items: [{ planPalletId: '11' }] }) }
|
||||
assert.fail(path)
|
||||
})
|
||||
const result = await execute(ctx, { cancellation: fresh({ allowDispatchCancellation: true, dispatchNo: 'DP', transferBody: { fees: [] }, rearrangeBody: { planPalletIds: ['11'] }, candidateQuery: { sourceStage: 'RECEIVED' } }) })
|
||||
assert.equal(result.cases.cancellation.status, 'PASS')
|
||||
assert.equal(ctx.calls.filter(row => row.method === 'DELETE').length, 1)
|
||||
assert.equal(ctx.calls.some(row => row.method === 'DELETE' && row.path.includes('/items/')), false)
|
||||
})
|
||||
test('retired pre-departure cancellation is not reported as verified',async()=>{const ctx=context(),proof=await execute(ctx,{});assert.equal(proof.cases.cancellation.status,'NOT_APPLICABLE');assert.equal(ctx.calls.length,0);assert.notEqual(proof.status,'VERIFIED')})
|
||||
|
||||
test('domain rejection cannot be faked by a missing permission; actual shipment cancellation leaves facts unchanged', async () => {
|
||||
for (const forbidden of [false, true]) {
|
||||
|
||||
@@ -100,11 +100,11 @@ async function loadProducerResources(context) {
|
||||
must(id(data.overseasWarehouseId) === sources[0].warehouseId && same((data.items || []).map(row => id(row.warehousePalletId)).sort(), sources.map(row => id(row.candidate.warehousePalletId)).sort()), 'Dispatch birth response differs from producer selection')
|
||||
add('dispatch', data.dispatchNo, sources[0].customerId, sources[0].warehouseId, e, { dispatch: data, arrangeBody: e.body })
|
||||
}
|
||||
match = path.match(/\/dispatches\/([^/]+)\/transfer-delivery$/)
|
||||
match = path.match(/\/dispatches\/([^/]+)$/)
|
||||
const dispatched = match && own.dispatch.get(decodeURIComponent(match[1]))
|
||||
if (e.method === 'POST' && dispatched && data.outboundNo && !data.replayed) {
|
||||
add('outbound', data.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key })
|
||||
for (const row of data.deliveries || []) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: data.outboundNo, dispatchNo: dispatched.key })
|
||||
if (e.method === 'GET' && dispatched && data.stage === 'DELIVERING') {
|
||||
for (const row of data.documents?.outbounds || []) if (id(row.customerId) === dispatched.customerId) add('outbound', row.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key })
|
||||
for (const row of data.documents?.deliveries || []) if (id(row.customerId) === dispatched.customerId && own.outbound.has(row.outboundNo)) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: row.outboundNo, dispatchNo: dispatched.key })
|
||||
}
|
||||
match = path.match(/\/delivery-orders\/([^/]+)\/return-inbound-options$/)
|
||||
if (e.method === 'GET' && match && own.delivery.has(decodeURIComponent(match[1])) && Array.isArray(data)) {
|
||||
@@ -242,39 +242,7 @@ export async function verifyDispatchRepair(context, fixtures, { execute = false
|
||||
finally { await context.checkpoint() }
|
||||
}
|
||||
try {
|
||||
await run('cancellation', async f => {
|
||||
must(f.allowDispatchCancellation === true && f.dispatchNo && f.transferBody && f.rearrangeBody, 'Owned cancellation fixture and explicit business cancel scope required')
|
||||
const path = `${BASE}/dispatches/${encodeURIComponent(f.dispatchNo)}`
|
||||
const original = await call('GET', path)
|
||||
must(original.status === 'PENDING_DEPARTURE' && original.createdAt && original.actions?.canTransfer, 'New unpicked execution required')
|
||||
const transferBody = { ...f.transferBody, idempotencyKey: key() }
|
||||
const transfer = await call('POST', `${path}/transfer-delivery`, transferBody)
|
||||
must(transfer.outboundNo && transfer.deliveries?.length, 'New outbound and delivery required')
|
||||
const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(transfer.deliveries[0].deliveryNo)}`, f.customerId)
|
||||
const before = await call('GET', dpath)
|
||||
must(before.order?.status === 'WAIT_DELIVERY' && before.order.outboundNo === transfer.outboundNo, 'Delivery must belong to new unexecuted outbound')
|
||||
for (const status of ['DELIVERING', 'REJECTED', 'CANCELLED']) {
|
||||
await call('PUT', scope(`${BASE}/delivery-orders/${encodeURIComponent(before.order.deliveryNo)}/status`, f.customerId),
|
||||
{ status, expectedVersion: before.order.version, remark: context.marker }, { failure: true })
|
||||
}
|
||||
must(same(shipment((await call('GET', dpath)).order), shipment(before.order)), 'Rejected status commands changed delivery')
|
||||
const opath = `${BASE}/outbound-orders/${encodeURIComponent(transfer.outboundNo)}`
|
||||
await call('POST', `${opath}/cancel`, { reason: context.marker })
|
||||
must((await call('GET', opath)).outbound?.status === 'CANCELLED', 'Outbound cancellation did not persist')
|
||||
must((await call('GET', dpath)).order?.status === 'CANCELLED', 'Linked delivery cancellation did not persist')
|
||||
await call('DELETE', path, undefined, { cancel: true })
|
||||
must((await call('GET', path)).status === 'CANCELLED', 'Original dispatch was not retained as cancelled')
|
||||
const candidateQuery = new URLSearchParams(f.candidateQuery)
|
||||
const candidates = await call('GET', `${BASE}/dispatches/candidates?${candidateQuery}`)
|
||||
const expected = f.rearrangeBody.planPalletIds.map(id)
|
||||
must(expected.length && expected.every(value => candidates.some(row => id(row.planPalletId) === value)), 'Cancelled source pallets were not released')
|
||||
const replacement = await call('POST', `${BASE}/dispatches/pre-arrange`, f.rearrangeBody)
|
||||
must(replacement.dispatchNo && replacement.dispatchNo !== f.dispatchNo && replacement.status === 'PENDING_DEPARTURE'
|
||||
&& same(replacement.items.map(row => id(row.planPalletId)).sort(), [...expected].sort()), 'Released source was not re-arranged exactly once')
|
||||
const replay = await call('POST', `${path}/transfer-delivery`, transferBody)
|
||||
must(replay.outboundNo === transfer.outboundNo && replay.replayed, 'Old transfer replay generated another outbound')
|
||||
return { originalDispatchNo: f.dispatchNo, cancelledOutboundNo: transfer.outboundNo, replacementDispatchNo: replacement.dispatchNo }
|
||||
})
|
||||
proof.cases.cancellation = { status: 'NOT_APPLICABLE', reason: '装车完成即派送,已撤销未发车出库取消及人工安排派送场景' }
|
||||
await run('departedGate', async f => {
|
||||
const opath = `${BASE}/outbound-orders/${encodeURIComponent(f.outboundNo)}`
|
||||
const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}`, f.customerId)
|
||||
|
||||
@@ -9,10 +9,10 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
const clone = value => structuredClone(value)
|
||||
const id = value => String(value ?? '')
|
||||
export const extensionMatrix = Object.freeze([
|
||||
{ scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' },
|
||||
{ scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; automatic isolated execution documents and actual fee facts', readiness: 'READY' },
|
||||
{ scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' },
|
||||
{ scenario: 'relabel', scope: 'Fresh three-box customer receipt, confirmed zero-scan cancellation, two applications sharing one pallet and native rearrangement', readiness: 'READY_FORMAL_THREE_BOX_RECEIPT' },
|
||||
{ scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' },
|
||||
{ scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' },
|
||||
{ scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' },
|
||||
])
|
||||
export function assertDualDestinationFixture(fixture) {
|
||||
@@ -30,24 +30,6 @@ export function feeCents(value) {
|
||||
const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'))
|
||||
assert(Number.isSafeInteger(cents)); return cents
|
||||
}
|
||||
export function applyIndependentFees(body, groups, members, marker) {
|
||||
assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2)
|
||||
assert.equal(new Set(groups.map(row => row.groupKey)).size, 2)
|
||||
assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2)
|
||||
const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775]))
|
||||
const expected = {}
|
||||
for (const group of body.groups) {
|
||||
const matches = groups.filter(row => row.groupKey === group.groupKey)
|
||||
assert.equal(matches.length, 1)
|
||||
const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId)
|
||||
assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required')
|
||||
assert(!expected[customerId], 'Duplicated customer fee group')
|
||||
for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` }
|
||||
expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) }))
|
||||
}
|
||||
return expected
|
||||
}
|
||||
|
||||
/** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */
|
||||
export async function discoverSecondDestination(context, primaryId) {
|
||||
const get = async path => {
|
||||
@@ -88,17 +70,13 @@ export async function verifyDualDestination(context, fixture, options) {
|
||||
}
|
||||
const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker,
|
||||
dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false,
|
||||
hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] }
|
||||
hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', isolation: [] }
|
||||
const original = context.request
|
||||
let arrangement, deferredFailure, raceDone = false, firstRaceDone = false, unarrangedDone = false
|
||||
let deferredFailure, raceDone = false, firstRaceDone = false
|
||||
const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`
|
||||
context.request = async function (method, path, body, ...rest) {
|
||||
if (deferredFailure) throw deferredFailure
|
||||
if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) {
|
||||
proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker)
|
||||
proof.arrangeRequest = clone(body); await context.checkpoint()
|
||||
}
|
||||
let response
|
||||
if (options.firstCompetition && !firstRaceDone && method === 'POST' && path === `${pdaPath}/loading-complete`
|
||||
&& fixture.tripProof.loadingCommand?.body?.idempotencyKey === body?.idempotencyKey) {
|
||||
@@ -107,21 +85,14 @@ export async function verifyDualDestination(context, fixture, options) {
|
||||
response = outcome.pdaResponse
|
||||
if (outcome.error) deferredFailure = outcome.error
|
||||
} else response = await original.call(this, method, path, body, ...rest)
|
||||
if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement`
|
||||
&& response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data)
|
||||
// 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。
|
||||
if (!deferredFailure && response.http === 200 && String(response.body?.code) === '200') {
|
||||
try {
|
||||
if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone
|
||||
&& response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') {
|
||||
&& response.body.data?.replayed !== true && response.body.data?.stage === 'DELIVERING') {
|
||||
raceDone = true
|
||||
await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data)
|
||||
}
|
||||
if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone
|
||||
&& response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) {
|
||||
unarrangedDone = true
|
||||
await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data)
|
||||
}
|
||||
if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) {
|
||||
const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true }
|
||||
for (let attempt = 1; attempt <= 60; attempt++) {
|
||||
@@ -154,11 +125,9 @@ export async function verifyDualDestination(context, fixture, options) {
|
||||
assert.equal(id(delivery.order.customerId), member.customerId)
|
||||
assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode)
|
||||
assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1)
|
||||
const expected = proof.expectedFees[member.customerId]
|
||||
assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length)
|
||||
assert(Array.isArray(delivery.fees))
|
||||
for (const fee of delivery.fees) {
|
||||
assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id))
|
||||
assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount)))
|
||||
assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE')
|
||||
}
|
||||
const other = fixture.members[1 - index]
|
||||
@@ -170,7 +139,6 @@ export async function verifyDualDestination(context, fixture, options) {
|
||||
proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo,
|
||||
http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId })
|
||||
}
|
||||
assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId])
|
||||
proof.status = 'VERIFIED'; await context.checkpoint(); return verified
|
||||
}
|
||||
|
||||
@@ -238,11 +206,11 @@ export async function executeFirstLoadingRace(context, fixture, options, send, b
|
||||
assert.equal(winnerIndexes.length, 1)
|
||||
const winnerIndex = winnerIndexes[0], winner = responses[winnerIndex], loser = responses[1 - winnerIndex]
|
||||
assert.equal(String(loser.body?.code), 'A0409')
|
||||
assert.equal(winner.body.data.stage, 'AFTER_LOADING')
|
||||
assert.equal(winner.body.data.stage, 'DELIVERING')
|
||||
assert.equal(winner.body.data.result?.confirmedLoad?.manifestHash, body.manifestHash)
|
||||
assert.equal(BigInt(winner.body.data.recordVersion), BigInt(body.recordVersion) + 1n)
|
||||
assert(BigInt(winner.body.data.recordVersion) > BigInt(body.recordVersion))
|
||||
race.after = await competitionSnapshot(context, fixture, options, send)
|
||||
assert.equal(race.after.main.stage, 'AFTER_LOADING')
|
||||
assert.equal(race.after.main.stage, 'DELIVERING')
|
||||
assert.equal(id(race.after.main.recordVersion), id(winner.body.data.recordVersion))
|
||||
assert.deepEqual(race.after.stock, race.before.stock)
|
||||
assert.equal(Number(race.after.main.counts.packageCount), 2)
|
||||
@@ -259,20 +227,6 @@ export async function executeFirstLoadingRace(context, fixture, options, send, b
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) {
|
||||
const proof = context.createdResources.dispatchWorkflowCompetition
|
||||
const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send),
|
||||
body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } }
|
||||
assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0)
|
||||
await context.checkpoint()
|
||||
const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken)
|
||||
state.response = receipt(response); await context.checkpoint()
|
||||
assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/)
|
||||
state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before)
|
||||
context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS)
|
||||
state.status = 'VERIFIED'; await context.checkpoint()
|
||||
}
|
||||
|
||||
export async function verifyCompetitionExtension(context, fixture, options) {
|
||||
const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo,
|
||||
status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' }
|
||||
@@ -291,7 +245,7 @@ export async function verifyCompetitionExtension(context, fixture, options) {
|
||||
context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS)
|
||||
proof.resourceConflict.status = 'VERIFIED'
|
||||
const verified = await verifyDualDestination(context, fixture, { ...options, competition: true })
|
||||
assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED')
|
||||
assert.equal(proof.loadingReplayRace?.status, 'VERIFIED')
|
||||
if (options.firstCompetition) assert.equal(proof.firstLoadingRace?.status, 'VERIFIED')
|
||||
proof.status = options.firstCompetition ? 'VERIFIED_FIRST_EXECUTION_AND_REPLAY' : 'VERIFIED_BOUNDED_SCOPE'
|
||||
await context.checkpoint(); return verified
|
||||
@@ -784,10 +738,11 @@ export async function reconcileReviewTerminal(context, fixture, { readInventory
|
||||
assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED')
|
||||
assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED')
|
||||
assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode)
|
||||
const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length)
|
||||
assert(Array.isArray(delivery.fees))
|
||||
const originalFees=old.snapshots.BEFORE_SIGN_1.members[index].delivery.fees
|
||||
assert(Array.isArray(originalFees)); assert.deepEqual(delivery.fees,originalFees)
|
||||
for (const fee of delivery.fees) {
|
||||
assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id))
|
||||
assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount)))
|
||||
assert(fee.id && !allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id))
|
||||
}
|
||||
const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })
|
||||
assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs'
|
||||
import { feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs'
|
||||
import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs'
|
||||
|
||||
test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => {
|
||||
@@ -24,26 +24,6 @@ test('terminal continuation requires an explicit original report hash and is nev
|
||||
'--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)])
|
||||
assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64))
|
||||
})
|
||||
test('actual group owner controls distinct cents even when server group ordering differs', () => {
|
||||
const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }]
|
||||
const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) }
|
||||
const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT')
|
||||
assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25])
|
||||
assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775)
|
||||
assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL')
|
||||
assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成')))
|
||||
})
|
||||
test('unknown customers, repeated groups and missing real required fees fail before transport', () => {
|
||||
const members = [{ customerId: 'A' }, { customerId: 'B' }]
|
||||
for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }],
|
||||
[{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) {
|
||||
const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) }
|
||||
assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT'))
|
||||
}
|
||||
assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] },
|
||||
[{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT'))
|
||||
})
|
||||
test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => {
|
||||
const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true },
|
||||
{ customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }],
|
||||
@@ -120,10 +100,10 @@ for (const winner of ['PDA', 'ADMIN']) test(`first valid loading CAS race record
|
||||
const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } }, checkpoint: async () => {}, assert: () => {} }
|
||||
const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA', members: [{ customerId: 'A' }, { customerId: 'B' }] }
|
||||
const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL_PDA', manifestHash: 'ACTUAL_HASH' }
|
||||
const success = { http: 200, body: { code: '200', data: { stage: 'AFTER_LOADING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } }
|
||||
const success = { http: 200, body: { code: '200', data: { stage: 'DELIVERING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } }
|
||||
const rejected = { http: 200, body: { code: 'A0409' } }, pending = []; let completed = false
|
||||
const send = async (method, path, payload, token) => {
|
||||
if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'AFTER_LOADING' : 'LOADING',
|
||||
if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'DELIVERING' : 'LOADING',
|
||||
recordVersion: completed ? '8' : '7', counts: { packageCount: 2, pickedPackageCount: 2 } } } }
|
||||
return new Promise(resolve => {
|
||||
pending.push({ resolve, token, payload })
|
||||
|
||||
@@ -35,9 +35,10 @@ test('one booking maps to exactly one commercial order and one actual incoming s
|
||||
})
|
||||
|
||||
test('module and unified fresh baselines contain the exact same new table definitions', () => {
|
||||
const module = read('lastleg-service/src/main/resources/db/lastleg.sql')
|
||||
// 比较定义而不是文件换行;仍要求列、约束及表选项完全相同。
|
||||
const module = read('lastleg-service/src/main/resources/db/lastleg.sql').replace(/\r\n/g, '\n')
|
||||
for (const name of ['ll_fleet_capacity_offer', 'll_fleet_capacity_offer_command', 'll_fleet_pool_order_link']) {
|
||||
const definition = table(overseas, 'baiyue_lastleg', name)
|
||||
const definition = table(overseas, 'baiyue_lastleg', name).replace(/\r\n/g, '\n')
|
||||
assert.ok(module.includes(definition), `${name}: module and unified contract drift`)
|
||||
}
|
||||
const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql')
|
||||
|
||||
@@ -43,8 +43,8 @@ function model(fault = '') {
|
||||
if (method === 'POST' && keys.has(body?.idempotencyKey)) {
|
||||
const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body)
|
||||
if(token!==prior.actor) {
|
||||
if(fault==='foreign departure actor accepted'&&path.endsWith('/depart'))return response({...prior.result,replayed:true})
|
||||
if(fault==='foreign departure actor mutates'&&path.endsWith('/depart'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'})
|
||||
if(fault==='foreign departure actor accepted'&&path.endsWith('/loading-complete'))return response({...prior.result,replayed:true})
|
||||
if(fault==='foreign departure actor mutates'&&path.endsWith('/loading-complete'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'})
|
||||
return forbidden()
|
||||
}
|
||||
return response({...prior.result,replayed:true})
|
||||
@@ -56,6 +56,7 @@ function model(fault = '') {
|
||||
if(fault==='aggregation delayed'&&state.every(s=>s.delivery?.order.status==='DELIVERED')) {
|
||||
if(aggregationReads===0)stage='DELIVERED';else aggregationReads--
|
||||
}
|
||||
if(stopAtArrangement&&stage==='DELIVERING'&&fixture.tripProof.loadingCommand?.afterReplay)throw Error('Explicit test checkpoint after confirmed loading')
|
||||
return response(detail())
|
||||
}
|
||||
if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST',
|
||||
@@ -64,11 +65,15 @@ function model(fault = '') {
|
||||
evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))})
|
||||
if (path.endsWith('/loading-complete')) {
|
||||
if (!state.every(s=>s.picked===1)) {
|
||||
if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]]
|
||||
if(fault==='gate mutates second customer')state[1].stock.balances[0].holdPackages='1'
|
||||
if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'}
|
||||
return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平')
|
||||
}
|
||||
assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2)
|
||||
stage='AFTER_LOADING';nativeVersion++
|
||||
stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT'
|
||||
for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'OUTBOUNDED',packageCount:1};s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:fault==='partial departure'&&i===1?'WAIT_DELIVERY':'DELIVERING',version:0}} }
|
||||
if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'}
|
||||
confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'}
|
||||
return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE')
|
||||
}
|
||||
@@ -82,29 +87,6 @@ function model(fault = '') {
|
||||
}
|
||||
if (path === '/api/pda/v1/last-leg/dispatches/DS-ONE') return response({dispatchNo:'DS-ONE',warehouseId:'WH',status:'ACTIVE',stage,recordVersion:nativeVersion,counts:counts(),pickingReconciled:state.every(s=>s.picked===1),
|
||||
items:items().map((item,i)=>({...item,positions:fault==='missing PDA position'?[]:[{positionId:members[i].positionId,balanceId:members[i].balanceId,availablePackages:state[i].stock.positions[0].availablePackages}]}))})
|
||||
if (path.endsWith('/delivery-arrangement')) {
|
||||
if(stopAtArrangement)throw Error('Explicit test checkpoint after confirmed loading')
|
||||
return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true},arrangement:arranged?{status:'ARRANGED'}:null,
|
||||
groups:members.map((m,i)=>({groupKey:`GROUP${i}`,customerId:m.customerId,groupCargoHash:`GROUP-HASH${i}`,destination:{key:i?'FBA:B':'FBA:A'},cargo:[{customerId:m.customerId,warehousePalletId:m.palletId}],blockReasons:[],requiredServices:['DELIVERY_FEE']}))})
|
||||
}
|
||||
if (path.endsWith('/transfer-delivery')) {
|
||||
assert.equal(stage,'AFTER_LOADING');assert.equal(body.recordVersion,nativeVersion);assert.equal(body.groups.length,2)
|
||||
assert.ok(body.groups.every(g=>g.fees.length===1&&g.fees[0].amount===30))
|
||||
for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'WAIT_OUTBOUND'}
|
||||
s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:'WAIT_DELIVERY',version:0}} }
|
||||
arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()},token)
|
||||
}
|
||||
if (path.endsWith('/depart')) {
|
||||
if(!arranged) {
|
||||
if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]]
|
||||
if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'}
|
||||
return reject('请先完成装车并一次安排全部客户派送')
|
||||
}
|
||||
assert.equal(body.recordVersion,nativeVersion);stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT'
|
||||
for(const [i,s]of state.entries())if(!(fault==='partial departure'&&i===1)){s.outbound.status='OUTBOUNDED';s.delivery.order.status='DELIVERING'}
|
||||
if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'}
|
||||
return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)},token)
|
||||
}
|
||||
const index=members.findIndex((_m,i)=>path.includes(`/OUT${i}`)||path.includes(`/DL${i}`)),row=state[index]
|
||||
if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]})
|
||||
if(path.endsWith('/documents')) {
|
||||
@@ -127,7 +109,7 @@ test('default call and import do not request, create or activate anything',async
|
||||
test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{
|
||||
const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory})
|
||||
assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED')
|
||||
assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,4)
|
||||
assert.equal(m.calls.some(c=>c.raw.endsWith('/depart')),false)
|
||||
assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED'])
|
||||
assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken))
|
||||
assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken))
|
||||
@@ -148,10 +130,10 @@ test('explicit rollback verifier runs after both picks and before successful dep
|
||||
const m=model();let calls=0
|
||||
const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory,
|
||||
verifyDepartureRollback:async({ready,departPath,departBody})=>{
|
||||
calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'AFTER_LOADING')
|
||||
assert.ok(ready.members.every(row=>row.outbound.outbound.status==='WAIT_OUTBOUND'))
|
||||
assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1)
|
||||
assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.equal(departBody.idempotencyKey,'RUN-CROSS-DEPART')
|
||||
calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'LOADING')
|
||||
assert.ok(ready.members.every(row=>!row.outbound))
|
||||
assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1)
|
||||
assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.ok(departBody.idempotencyKey)
|
||||
return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'}
|
||||
}})
|
||||
assert.equal(calls,1);assert.equal(proof.midTransactionRollback,'VERIFIED')
|
||||
@@ -164,8 +146,8 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati
|
||||
if(failure==='mutates')m.state[0].stock.balances[0].availablePackages='-1'
|
||||
return {verified:failure!=='unverified',scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'}
|
||||
}}))
|
||||
assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1)
|
||||
assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY'))
|
||||
assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1)
|
||||
assert.ok(m.state.every(row=>row.delivery===null))
|
||||
})
|
||||
for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery','foreign departure actor accepted','foreign departure actor mutates']) {
|
||||
test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{
|
||||
@@ -194,7 +176,7 @@ async function afterLoadingModel(fault = '') {
|
||||
const priorProof = structuredClone(m.context.createdResources.fleetCrossCustomer)
|
||||
assert.equal(priorProof.status,'FAILED')
|
||||
assert.equal(priorProof.gateProof,'PREREQUISITE_REJECTION_VERIFIED')
|
||||
assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'AFTER_LOADING')
|
||||
assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'DELIVERING')
|
||||
delete m.context.createdResources.fleetCrossCustomer
|
||||
m.fixture.stage = afterLoadingContinuationContract.stage
|
||||
m.resumeAfterLoading()
|
||||
@@ -227,7 +209,7 @@ test('explicit after-loading continuation reconciles original proof and never re
|
||||
const m=await afterLoadingModel(),original=structuredClone(m.priorProof),loading=structuredClone(m.fixture.tripProof.loadingCommand)
|
||||
const proof=await continueLoaded(m)
|
||||
assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true)
|
||||
assert.equal(proof.continuation.mode,'EXPLICIT_AFTER_LOADING')
|
||||
assert.equal(proof.continuation.mode,'EXPLICIT_COMPLETED_LOADING')
|
||||
assert.equal(proof.continuation.gateProofSource,'PRIOR_PROOF')
|
||||
assert.match(proof.continuation.priorProofSHA256,/^[a-f0-9]{64}$/)
|
||||
const normalize=value=>Array.isArray(value)?value.map(normalize):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value
|
||||
@@ -235,33 +217,12 @@ test('explicit after-loading continuation reconciles original proof and never re
|
||||
assert.deepEqual(m.priorProof,original);assert.deepEqual(m.fixture.tripProof.loadingCommand,loading)
|
||||
assert.equal(proof.snapshots.AFTER_PICK_1,undefined,'no historical loading snapshot is fabricated in the new proof')
|
||||
assert.ok(proof.snapshots.CONTINUATION_INITIAL);assert.deepEqual(proof.continuation.originalAfterPickSnapshot,original.snapshots.AFTER_PICK_1)
|
||||
assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false)
|
||||
assert.equal(writes(m).filter(call=>call.raw.endsWith('/transfer-delivery')).length,2,'one arrangement plus the exact original-key replay')
|
||||
const departures=writes(m).filter(call=>call.raw.endsWith('/depart'))
|
||||
assert.deepEqual(departures.map(call=>call.token),[m.fixture.pdaToken,m.fixture.pdaToken,m.context.token])
|
||||
assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')),false)
|
||||
assert.equal(writes(m).some(call=>call.raw.endsWith('/transfer-delivery')||call.raw.endsWith('/depart')),false)
|
||||
assert.deepEqual(m.state.map(row=>row.delivery.order.status),['DELIVERED','DELIVERED'])
|
||||
assert.equal(JSON.stringify(proof).includes(m.fixture.pdaToken),false)
|
||||
})
|
||||
|
||||
test('after-loading accepts the official NON_NULL preview when the unarranged field is omitted',async()=>{
|
||||
const m=await afterLoadingModel()
|
||||
let omitted=false
|
||||
alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{
|
||||
delete value.arrangement
|
||||
omitted=!Object.hasOwn(value,'arrangement')
|
||||
})
|
||||
const proof=await continueLoaded(m)
|
||||
assert.equal(omitted,true);assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true)
|
||||
assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false)
|
||||
})
|
||||
|
||||
test('after-loading rejects even an empty existing arrangement object before any business write',async()=>{
|
||||
const m=await afterLoadingModel()
|
||||
alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={}})
|
||||
await assert.rejects(continueLoaded(m),/Continuation has no prior arrangement/)
|
||||
assert.equal(writes(m).length,0)
|
||||
})
|
||||
|
||||
for(const failure of ['missing prior','marker','gate','source identity','prior picking','unknown prior write','loading result','loading manifest','loading pallet','loading version']) {
|
||||
test(`after-loading refuses incomplete or foreign source proof before any request: ${failure}`,async()=>{
|
||||
const m=await afterLoadingModel()
|
||||
@@ -279,7 +240,7 @@ for(const failure of ['missing prior','marker','gate','source identity','prior p
|
||||
})
|
||||
}
|
||||
|
||||
for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity','manifest drift','confirmed load drift','existing arrangement']) {
|
||||
for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity']) {
|
||||
test(`after-loading refuses changed current facts before arranging or departing: ${failure}`,async()=>{
|
||||
const m=await afterLoadingModel()
|
||||
if(failure==='inventory drift')m.state[0].stock.ledger.push({...m.state[0].stock.ledger[0],id:'UNEXPECTED-MOVEMENT'})
|
||||
|
||||
@@ -30,8 +30,8 @@ export const fixtureContract = Object.freeze({
|
||||
|
||||
export const afterLoadingContinuationContract = Object.freeze({
|
||||
...fixtureContract,
|
||||
stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_AFTER_LOADING_NOT_ARRANGED',
|
||||
preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current AFTER_LOADING with no arrangement or execution documents.',
|
||||
stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_DELIVERING',
|
||||
preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current DELIVERING with automatically created execution documents.',
|
||||
boundary: 'Reconcile original identities, complete picked inventory and original loading receipt through current reads before any new write. Never repeat picking/loading or rewrite the prior proof.',
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal
|
||||
|
||||
export async function continueFleetCrossCustomerAfterLoading(context, fixture, { execute = false, readInventory, verifyDepartureRollback, priorProof, completionPolling } = {}) {
|
||||
if (!execute) return afterLoadingContinuationContract
|
||||
if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for AFTER_LOADING continuation')
|
||||
if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for completed-loading continuation')
|
||||
return runCrossCustomerCore(context, { ...fixture, members: structuredClone(fixture?.members) },
|
||||
{ readInventory, verifyDepartureRollback, priorProof, completionPolling })
|
||||
}
|
||||
@@ -161,84 +161,29 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
|
||||
equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record')
|
||||
}
|
||||
try {
|
||||
let cargoBasis, originalLoadingHash
|
||||
let cargoBasis
|
||||
if (continuation) {
|
||||
const prior = priorProof.snapshots?.AFTER_PICK_1, command = fixture.tripProof.loadingCommand
|
||||
const confirmed = command?.result?.result?.confirmedLoad
|
||||
check('Continuation has the original failed owned proof and verified prerequisite gate', priorProof.status === 'FAILED'
|
||||
&& priorProof.marker === context.marker && priorProof.dispatchNo === fixture.dispatchNo && priorProof.tripNo === fixture.tripNo
|
||||
&& id(priorProof.warehouseId) === id(fixture.warehouseId) && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED'
|
||||
&& priorProof.members?.length === 2 && !priorProof.arrangeRequest && !priorProof.departure
|
||||
&& !priorProof.members.some(member => member.outboundNo || member.deliveryNo)
|
||||
&& Array.isArray(priorProof.requests) && priorProof.requests.every(entry => ['GET','HEAD','OPTIONS'].includes(entry.method) || entry.status === 'RESPONDED'))
|
||||
const identityFields = ['marker','customerId','incomingNo','dispatchNo','dispatchItemId','balanceId','positionId','palletId','palletNo']
|
||||
check('Continuation preserves each original customer, source, item, balance, position and physical pallet', fixture.members.every((member,index) =>
|
||||
identityFields.every(field => id(member[field]) === id(priorProof.members[index]?.[field]))))
|
||||
check('Prior AFTER_PICK_1 is a complete reviewed picked but not shipped native snapshot', prior?.dispatch?.dispatchNo === fixture.dispatchNo
|
||||
&& prior.dispatch.status === 'ACTIVE' && prior.dispatch.stage === 'LOADING' && prior.dispatch.reviewState === 'APPROVED'
|
||||
&& prior.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && prior.dispatch.reviews?.loadingReview?.status === 'APPROVED'
|
||||
&& integer(prior.dispatch.counts?.packageCount) === 2 && integer(prior.dispatch.counts?.pickedPackageCount) === 2
|
||||
&& integer(prior.dispatch.counts?.shippedPackageCount) === 0 && prior.task?.pickingReconciled === true
|
||||
&& prior.task.dispatchNo === fixture.dispatchNo && id(prior.task.warehouseId) === id(fixture.warehouseId)
|
||||
&& prior.task.stage === 'LOADING' && id(prior.task.recordVersion) === id(prior.dispatch.recordVersion)
|
||||
&& prior.items?.dispatchNo === fixture.dispatchNo && id(prior.items.recordVersion) === id(prior.dispatch.recordVersion)
|
||||
&& prior.items.items?.length === 2 && prior.task.items?.length === 2
|
||||
&& prior.members?.length === 2 && prior.members.every(row => row.stock && !row.outbound && !row.delivery)
|
||||
&& prior.dispatch.documents?.outbounds?.length === 0 && prior.dispatch.documents?.deliveries?.length === 0)
|
||||
check('Original loading command has a successful explicit receipt for this main and both exact physical pallets', command?.path === `${pdaPath}/loading-complete`
|
||||
&& command.body?.idempotencyKey && command.body.manifestHash && command.result?.dispatchNo === fixture.dispatchNo
|
||||
&& command.result.action === 'LOADING_COMPLETE' && command.result.idempotencyKey === command.body.idempotencyKey
|
||||
&& command.result.status === 'ACTIVE' && command.result.stage === 'AFTER_LOADING' && command.result.result?.loadingCompletionHash
|
||||
&& id(command.body.recordVersion) === id(prior.dispatch.recordVersion)
|
||||
&& /^(0|[1-9]\d*)$/.test(id(command.result.recordVersion))
|
||||
&& /^(0|[1-9]\d*)$/.test(id(command.body.recordVersion))
|
||||
&& BigInt(id(command.result.recordVersion)) > BigInt(id(command.body.recordVersion))
|
||||
&& integer(command.result.counts?.packageCount) === 2 && integer(command.result.counts?.pickedPackageCount) === 2
|
||||
&& integer(command.result.counts?.shippedPackageCount) === 0
|
||||
&& confirmed?.manifestHash === command.body.manifestHash && confirmed.operatorId && Number.isFinite(Date.parse(confirmed.confirmedAt))
|
||||
&& canonical(confirmed) === canonical(fixture.tripProof.confirmedLoad)
|
||||
&& Array.isArray(command.body.measurements) && command.body.measurements.length === 2
|
||||
&& canonical(command.body.measurements) === canonical(confirmed.pallets)
|
||||
&& new Set(confirmed.pallets.map(row => id(row.warehousePalletId))).size === 2
|
||||
&& fixture.members.every(member => confirmed.pallets.some(row => id(row.warehousePalletId) === member.palletId
|
||||
&& Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0
|
||||
&& ['weightKg','volumeCbm'].every(field => ['number','string'].includes(typeof row[field])
|
||||
&& /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number.isFinite(Number(row[field])) && Number(row[field]) > 0)
|
||||
&& Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0 && fixture.tripProof.stops?.[row.deliveryIndex]?.destinationKey)))
|
||||
proof.continued = true
|
||||
proof.continuation = { mode: 'EXPLICIT_AFTER_LOADING', priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex'),
|
||||
priorStatus: priorProof.status, gateProofSource: 'PRIOR_PROOF', originalAfterPickSnapshot: structuredClone(prior),
|
||||
originalLoadingCommand: structuredClone(command), originalConfirmedLoad: structuredClone(confirmed) }
|
||||
proof.gateProof = priorProof.gateProof
|
||||
await context.checkpoint?.()
|
||||
const initial = await snapshot('CONTINUATION_INITIAL')
|
||||
check('Current main is exactly the unarranged confirmed loading version with two picked and zero shipped packages', initial.dispatch.status === 'ACTIVE'
|
||||
&& initial.dispatch.stage === 'AFTER_LOADING' && initial.task.stage === 'AFTER_LOADING' && initial.trip.trip.status === 'PLANNED'
|
||||
&& id(initial.dispatch.recordVersion) === id(command.result.recordVersion) && initial.task.pickingReconciled === true
|
||||
&& initial.dispatch.reviewState === 'APPROVED' && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED'
|
||||
&& initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' && integer(initial.dispatch.counts.packageCount) === 2
|
||||
&& integer(initial.dispatch.counts.pickedPackageCount) === 2 && integer(initial.dispatch.counts.shippedPackageCount) === 0
|
||||
&& initial.dispatch.documents?.outbounds?.length === 0 && initial.dispatch.documents?.deliveries?.length === 0
|
||||
&& id(initial.trip.trip.vehicleId) === id(fixture.tripProof.vehicleId) && id(initial.trip.trip.driverId) === id(fixture.tripProof.driverId)
|
||||
&& id(prior.trip?.trip?.vehicleId) === id(fixture.tripProof.vehicleId) && id(prior.trip?.trip?.driverId) === id(fixture.tripProof.driverId)
|
||||
&& canonical(initial.trip.trip.stops) === canonical(fixture.tripProof.stops)
|
||||
&& initial.items.items.every(item => integer(item.pickedPackages) === 1) && initial.task.items.every(item => integer(item.pickedPackages) === 1))
|
||||
equal(prior.members.map(row => row.stock), initial.members.map(row => row.stock), 'Continuation current complete inventory equals original AFTER_PICK_1 inventory')
|
||||
const sourceItems = items => items.map(item => Object.fromEntries(['dispatchItemId','customerId','sourceKey','sourceVersion','sourceType',
|
||||
'planPalletId','returnReceiptId','relabelNo','warehousePalletId','palletNo','orderNo','inboundCode','destination','scheduledPackages','pickedPackages']
|
||||
.map(field => [field,item[field]]))).sort((a,b) => id(a.dispatchItemId).localeCompare(id(b.dispatchItemId)))
|
||||
equal(sourceItems(prior.items.items), sourceItems(initial.items.items), 'Continuation preserves both picked source items and destinations')
|
||||
const loading = await request('GET', `${pdaPath}/loading`, undefined, 'Reconcile current native manifest against original successful loading receipt', true)
|
||||
check('Current loading preview preserves the original manifest, confirmed receipt and two reconciled physical pallets', loading.dispatchNo === fixture.dispatchNo
|
||||
&& loading.status === 'ACTIVE' && loading.stage === 'AFTER_LOADING' && id(loading.recordVersion) === id(command.result.recordVersion)
|
||||
&& loading.pickingReconciled === true && loading.manifestHash === confirmed.manifestHash
|
||||
&& canonical(loading.confirmedLoad) === canonical(confirmed) && loading.pallets?.length === 2
|
||||
&& new Set(loading.pallets.map(row => id(row.warehousePalletId))).size === 2
|
||||
&& fixture.members.every(member => loading.pallets.some(row => id(row.warehousePalletId) === member.palletId
|
||||
&& row.palletNo === member.palletNo && integer(row.scheduledPackages) === 1 && integer(row.pickedPackages) === 1)))
|
||||
proof.continuation.currentLoadingPreview = loading
|
||||
cargoBasis = initial
|
||||
originalLoadingHash = command.result.result.loadingCompletionHash
|
||||
const command = fixture.tripProof.loadingCommand
|
||||
const prior = priorProof.snapshots?.AFTER_PICK_1
|
||||
check('Continuation preserves original successful loading command and current-run picked inventory', priorProof.dispatchNo === fixture.dispatchNo && priorProof.marker === context.marker && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED'
|
||||
&& canonical(priorProof.members) === canonical(proof.members)
|
||||
&& priorProof.requests.every(row => row.method === 'GET' || row.status === 'RESPONDED')
|
||||
&& Number(prior?.dispatch?.counts?.pickedPackageCount) === 2 && id(command?.body?.recordVersion) === id(prior?.dispatch?.recordVersion)
|
||||
&& canonical(command?.body?.measurements) === canonical(command?.result?.result?.confirmedLoad?.pallets)
|
||||
&& prior?.dispatch?.dispatchNo === fixture.dispatchNo && prior.dispatch.stage === 'LOADING'
|
||||
&& command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' && command.result.stage === 'DELIVERING'
|
||||
&& command.result.dispatchNo === fixture.dispatchNo && command.result.idempotencyKey === command.body.idempotencyKey
|
||||
&& command.result.result?.confirmedLoad?.manifestHash === command.body.manifestHash)
|
||||
const current = await snapshot('CONTINUATION_INITIAL')
|
||||
check('Continuation is already delivering with the original automatic documents', current.dispatch.stage === 'DELIVERING'
|
||||
&& current.task.stage === 'DELIVERING' && current.trip.trip.status === 'IN_TRANSIT'
|
||||
&& id(current.dispatch.recordVersion) === id(command.result.recordVersion) && Number(current.dispatch.counts.shippedPackageCount) === 2
|
||||
&& canonical(current.items.items) === canonical(prior.items.items)
|
||||
&& current.dispatch.documents?.outbounds?.length === 2 && current.dispatch.documents?.deliveries?.length === 2)
|
||||
equal(prior.members.map(row => row.stock), current.members.map(row => row.stock), 'Continuation preserves complete picked inventory')
|
||||
proof.continued = true; proof.gateProof = priorProof.gateProof
|
||||
proof.continuation = { mode: 'EXPLICIT_COMPLETED_LOADING', gateProofSource:'PRIOR_PROOF', originalAfterPickSnapshot:structuredClone(prior), priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex') }
|
||||
cargoBasis = prior
|
||||
} else {
|
||||
const initial = await snapshot('INITIAL')
|
||||
check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED'
|
||||
@@ -257,67 +202,46 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
|
||||
await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'),
|
||||
manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true,
|
||||
message => /拣货.*核平|尚未核平/.test(message))
|
||||
await request('POST', `${pdaPath}/depart`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-DEPART'), remark: context.marker },
|
||||
'Unready second customer blocks whole trip before execution', true, message => /装车|派送/.test(message))
|
||||
equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
|
||||
proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED'
|
||||
await pick(proof.members[1], 1)
|
||||
await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements)
|
||||
cargoBasis = proof.snapshots.AFTER_PICK_1
|
||||
await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements, async command => {
|
||||
if (verifyDepartureRollback === undefined) return
|
||||
check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function')
|
||||
const ready = await snapshot('READY')
|
||||
const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath: command.path, departBody: command.body, snapshot })
|
||||
check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK')
|
||||
equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Rollback restores both complete inventory snapshots before loading completion')
|
||||
proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED'
|
||||
})
|
||||
}
|
||||
const arrangement = await request('GET', `${path}/delivery-arrangement`, undefined, 'Read native loaded per-customer delivery arrangement')
|
||||
if (continuation) check('Continuation has no prior arrangement and retains the original loading completion hash', arrangement.arrangement == null
|
||||
&& arrangement.loadingCompletionHash === originalLoadingHash && id(arrangement.recordVersion) === id(fixture.tripProof.loadingCommand.result.recordVersion))
|
||||
check('Loaded arrangement groups cover exactly both real customers', arrangement.dispatchNo === fixture.dispatchNo && arrangement.status === 'ACTIVE'
|
||||
&& arrangement.stage === 'AFTER_LOADING' && arrangement.arrange?.allowed === true && arrangement.cargoHash && arrangement.loadingCompletionHash
|
||||
&& arrangement.groups?.length === 2 && proof.members.every(m => arrangement.groups.some(g => id(g.customerId) === m.customerId && g.cargo?.length
|
||||
&& g.cargo.every(c => id(c.customerId) === m.customerId && id(c.warehousePalletId) === m.palletId) && g.blockReasons?.length === 0
|
||||
&& g.destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key)))
|
||||
const arrangeBody = { recordVersion: arrangement.recordVersion, idempotencyKey: key('ARRANGE'), cargoHash: arrangement.cargoHash,
|
||||
loadingCompletionHash: arrangement.loadingCompletionHash, groups: arrangement.groups.map(g => ({ groupKey: g.groupKey, groupCargoHash: g.groupCargoHash,
|
||||
plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP',
|
||||
loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST合成资料`,
|
||||
externalFreightHash: g.externalFreightHash, feeCoverageCargoHash: g.feeCoverageCargoHash, feeCoverage: [],
|
||||
fees: (g.requiredServices || []).map(feeType => ({ feeType, feeName: 'TEST本轮派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 })) })) }
|
||||
proof.arrangeRequest = arrangeBody
|
||||
const arranged = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Arrange both customers after real loading before departure')
|
||||
check('Native arrangement creates customer-isolated outbounds and deliveries', arranged.dispatchNo === fixture.dispatchNo && arranged.stage === 'AFTER_LOADING'
|
||||
&& arranged.result?.deliveries?.length === 2 && arranged.result.outbounds?.length === 2
|
||||
&& new Set(arranged.result.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(arranged.result.outbounds.map(o => o.outboundNo)).size === 2)
|
||||
const detail = await request('GET', path, undefined, 'Read documents created by the one loading-complete command')
|
||||
const documents = detail.documents
|
||||
check('Automatic loading creates both customer-isolated outbounds and deliveries', detail.dispatchNo === fixture.dispatchNo && detail.stage === 'DELIVERING'
|
||||
&& documents?.deliveries?.length === 2 && documents.outbounds?.length === 2
|
||||
&& new Set(documents.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(documents.outbounds.map(o => o.outboundNo)).size === 2)
|
||||
for (const [index, member] of proof.members.entries()) {
|
||||
const deliveries = arranged.result.deliveries.filter(d => id(d.customerId) === member.customerId)
|
||||
check('Each customer has exactly its own source-specific delivery and outbound', deliveries.length === 1 && integer(deliveries[0].packageCount) === 1
|
||||
&& arranged.result.outbounds.some(o => id(o.customerId) === member.customerId && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1))
|
||||
const deliveries = documents.deliveries.filter(d => id(d.customerId) === member.customerId)
|
||||
check('Each customer has its own source destination and quantity', deliveries.length === 1 && deliveries[0].status === 'DELIVERING'
|
||||
&& integer(deliveries[0].packageCount) === 1 && deliveries[0].destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === member.customerId)?.destination?.key
|
||||
&& documents.outbounds.some(o => id(o.customerId) === member.customerId && o.status === 'OUTBOUNDED' && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1))
|
||||
Object.assign(member, { outboundNo: deliveries[0].outboundNo, deliveryNo: deliveries[0].deliveryNo })
|
||||
Object.assign(fixture.members[index], { outboundNo: member.outboundNo, deliveryNo: member.deliveryNo })
|
||||
}
|
||||
const arrangedReplay = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Replay exact native customer arrangement')
|
||||
check('Native arrangement replay keeps original documents and main version', arrangedReplay.replayed === true && id(arrangedReplay.recordVersion) === id(arranged.recordVersion)
|
||||
&& canonical(arrangedReplay.result) === canonical(arranged.result))
|
||||
const ready = await snapshot('READY'), departBody = { recordVersion: ready.task.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker }
|
||||
const departPath = `${pdaPath}/depart`
|
||||
if (verifyDepartureRollback !== undefined) {
|
||||
check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function')
|
||||
const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath, departBody, snapshot })
|
||||
check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK')
|
||||
equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Mid-transaction rollback restores both complete customer execution and inventory snapshots')
|
||||
proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED'
|
||||
}
|
||||
const departed = await request('POST', departPath, departBody, 'Depart one cross-customer native dispatch once', true)
|
||||
check('Departure includes both customers in the one actual native main', departed.dispatchNo === fixture.dispatchNo && departed.status === 'ACTIVE'
|
||||
&& departed.stage === 'DELIVERING' && integer(departed.result?.shippedPackages) === 2 && departed.result.deliveries?.length === 2)
|
||||
const command = fixture.tripProof.loadingCommand, departed = command.result
|
||||
proof.departure = departed
|
||||
const after = await snapshot('DEPARTED')
|
||||
check('Both customer outbounds completed and main delivering', after.trip.trip.status === 'IN_TRANSIT' && after.dispatch.stage === 'DELIVERING'
|
||||
&& after.members.every(row => row.outbound.outbound.status === 'OUTBOUNDED' && row.delivery.order.status === 'DELIVERING'))
|
||||
equal(ready.members.map(row => row.stock), after.members.map(row => row.stock), 'Departure does not deduct either inventory again')
|
||||
const replay = await request('POST', departPath, departBody, 'Original PDA actor exact replay of native departure', true)
|
||||
check('Shared departure replay is explicit and returns original result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion)
|
||||
equal(cargoBasis.members.map(row => row.stock), after.members.map(row => row.stock), 'Loading completion does not deduct either inventory again')
|
||||
const replay = await request('POST', command.path, command.body, 'Original PDA actor exact replay of loading completion', true)
|
||||
check('Loading replay returns the original complete result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion)
|
||||
&& canonical(replay.result) === canonical(departed.result))
|
||||
const replayed = await snapshot('DEPART_REPLAY')
|
||||
equal(after, replayed, 'Departure replay preserves both complete inventories and execution facts')
|
||||
await request('POST', `${path}/depart`, departBody, 'Different platform actor cannot reuse the PDA departure command key', false, 'ACTOR_FORBIDDEN')
|
||||
equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Different actor rejection preserves both complete inventories and execution facts')
|
||||
equal(after, replayed, 'Loading replay preserves both complete inventories and execution documents')
|
||||
await request('POST', `${path}/loading-complete`, command.body, 'Different platform actor cannot reuse the PDA loading key', false, 'ACTOR_FORBIDDEN')
|
||||
equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Actor rejection preserves both complete inventories and execution facts')
|
||||
for (const [index, member] of proof.members.entries()) {
|
||||
const before = await snapshot(`BEFORE_SIGN_${index}`), documentPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`, member.customerId)
|
||||
const docs = await request('GET', documentPath, undefined, 'Read this customer delivery document version')
|
||||
|
||||
+28
-79
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { verifyFleetExternalDispatch, nativeArrangementGroups } from './fleet-external-dispatch-acceptance.mjs'
|
||||
import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', TRIP = '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT'
|
||||
const OWN = '900719925474099301', EXT = '900719925474099302', clone = value => structuredClone(value)
|
||||
@@ -21,12 +21,13 @@ function fixture(fault = '', nativeMode = false) {
|
||||
const tripProof = { tripNo: 'TR-CURRENT', dispatchNo: 'DS-OWN', warehouseId: '92', vehicleId: '71', driverId: '72', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }],
|
||||
externalReceipt: { marker: 'CURRENT', status: 'VERIFIED', incomingNo: 'WT-CURRENT', bookingNo: 'BK-CURRENT', warehousePalletId: '33', palletNo: 'PAL-3', createRequest: { customerId: externalCustomer }, finalSegments: [segment], finalBooking: { cargo: bookingCargo, agreedAmount: 30 } } }
|
||||
if (nativeMode) tripProof.stops.pop()
|
||||
let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'AFTER_LOADING' : 'LOADING', bound = false, planned = false, review = true, saved
|
||||
let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'DELIVERING' : 'LOADING', bound = false, planned = false, review = true, saved
|
||||
const booking = () => ({ bookingNo: 'BK-CURRENT', customerId: externalCustomer, status: bound ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', dispatchNo: bound ? 'DS-OWN' : null, recordVersion: bound ? 1 : 0, cargo: bookingCargo, agreedAmount: 30, receipt: accepted })
|
||||
const counts = rows => ({ itemCount: rows.length, palletCount: rows.length, packageCount: rows.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: rows.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 })
|
||||
const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'AFTER_LOADING' : stage,
|
||||
const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'DELIVERING' : stage,
|
||||
reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } },
|
||||
counts: counts(full ? fullItems : items), pickingReconciled: !full && items.every(i => i.pickedPackages === i.scheduledPackages), dispatchInfo: { route: 'A1,A2' },
|
||||
documents: stage==='DELIVERING'&&!full?documents():{outbounds:[],deliveries:[]},
|
||||
orderDetails: (full ? fullItems : items).map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) })
|
||||
const ack = (action, result = {}) => ({ ...detail(), action, result, replayed: false })
|
||||
const groups = () => {
|
||||
@@ -42,6 +43,7 @@ function fixture(fault = '', nativeMode = false) {
|
||||
for (const group of raw) { const previous = merged.get(group.groupKey); if (previous) { previous.cargo.push(...group.cargo); previous.externalQuotes.push(...group.externalQuotes) } else merged.set(group.groupKey, group) }
|
||||
return [...merged.values()].map(g => ({ ...g, requiredServices: g.cargo.some(c => !g.externalQuotes.some(q => q.inboundCode === c.inboundCode)) ? ['DELIVERY_FEE'] : [] }))
|
||||
}
|
||||
const documents=()=>({outbounds:[...new Set(items.map(i=>i.customerId))].map(customerId=>({customerId,outboundNo:customerId===OWN?'OUT-OWN':'OUT-EXT',status:'OUTBOUNDED',packageCount:items.filter(i=>i.customerId===customerId).reduce((n,i)=>n+i.scheduledPackages,0)})),deliveries:groups().map((g,n)=>({groupKey:g.groupKey,customerId:g.customerId,destination:g.destination,outboundNo:g.customerId===EXT?'OUT-EXT':'OUT-OWN',deliveryNo:`DEL-${n}`,status:'DELIVERING',packageCount:g.cargo.reduce((n,c)=>n+c.packages,0)}))})
|
||||
const trip = () => ({ trip: { tripNo: 'TR-CURRENT', warehouseId: '92', vehicleId: '71', driverId: '72', recordVersion: fleetVersion, stops: tripProof.stops }, dispatches: [{ dispatchNo: fault === 'second-dispatch' ? 'DS-OTHER' : 'DS-OWN' }] })
|
||||
const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' })
|
||||
const context = { marker: 'CURRENT', createdResources: nativeMode ? { fleetTrips: [tripProof] } : { fleetTrips: [tripProof, { dispatchNo: 'DS-FULL', tripNo: 'TR-FULL', warehouseId: '92' }], fleetFeeCoverageCases: [full, currentCase] }, checkpoint: async () => {},
|
||||
@@ -49,8 +51,8 @@ function fixture(fault = '', nativeMode = false) {
|
||||
async request(method, raw, body, token) {
|
||||
calls.push({ method, path: raw, body: clone(body), token })
|
||||
const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), key = `${normalized}:${body?.idempotencyKey}`
|
||||
if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; return response({ ...clone(original.data), replayed: true }) }
|
||||
const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data) }); return response(data) }
|
||||
if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; if(path.endsWith('/loading-complete')&&token!==original.token)return {http:200,body:{code:'A0403',message:'该请求标识不属于当前操作员'},requestId:'R',traceId:'T'}; return response({ ...clone(original.data), replayed: true }) }
|
||||
const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data), token }); return response(data) }
|
||||
if (path === `${TRIP}/capacity-plan`) {
|
||||
if (method === 'POST') { assert.equal(body.recordVersion, fleetVersion); assert.equal(body.pallets.length, 3); assert.equal(body.pallets.find(p => p.warehousePalletId === '33').palletSpaces, 2); planned = true; fleetVersion++ }
|
||||
return response({ recordVersion: fleetVersion, manifestHash: 'c'.repeat(64), planValid: items.length === 2 || planned || fault === 'stale-plan',
|
||||
@@ -74,14 +76,11 @@ function fixture(fault = '', nativeMode = false) {
|
||||
}
|
||||
const delivery = path.match(/\/delivery-orders\/DEL-(\d)$/)
|
||||
if (delivery) {
|
||||
const index = Number(delivery[1]), group = groups()[index], input = saved.groups[index], who = group.customerId
|
||||
assert.equal(params.get('customerId'), who)
|
||||
const feeCoverage = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: group.cargo, operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z',
|
||||
references: input.feeCoverage.map(s => ({ services: s.services, source: { ...group.feeSources.find(f => f.financeFeeId === s.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId } })) } : null
|
||||
const fees = input.fees.map(f => ({ id: String(981 + index), feeDirection: f.direction, feeType: f.feeType, amount: f.amount, currency: f.currency, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }))
|
||||
if (group.externalQuotes.length) fees.push({ id: '980', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', amount: 30, currency: 'CNY', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' })
|
||||
if (fault === 'duplicate-fee' && group.externalQuotes.length && calls.filter(c => c.path === raw).length > 1) fees.push(clone(fees[0]))
|
||||
return response({ order: { customerId: who, dispatchNo: 'DS-OWN', deliveryNo: `DEL-${index}`, outboundNo: who === EXT ? 'OUT-EXT' : 'OUT-OWN', destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage })
|
||||
const index=Number(delivery[1]),group=groups()[index],who=group.customerId
|
||||
assert.equal(params.get('customerId'),who)
|
||||
const fees=group.externalQuotes.length?[{id:'980',feeDirection:'RECEIVABLE',feeType:'DELIVERY_FEE',amount:fault==='stale-price'?50:30,currency:'CNY',sourceStatus:'ACTIVE',reviewStatus:'APPROVED'}]:[]
|
||||
if(fault==='duplicate-fee'&&fees.length)fees.push(clone(fees[0]))
|
||||
return response({order:{customerId:who,dispatchNo:'DS-OWN',deliveryNo:`DEL-${index}`,outboundNo:who===EXT?'OUT-EXT':'OUT-OWN',destinationWarehouseCode:group.destination.warehouseCode,status:'DELIVERING',packageCount:group.cargo.reduce((n,c)=>n+c.packages,0)},fees,feeCoverage:null})
|
||||
}
|
||||
const isFull = path.startsWith(`${BASE}/DS-FULL`), data = detail(isFull), cargo = isFull ? fullItems : items
|
||||
if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) })
|
||||
@@ -89,7 +88,6 @@ function fixture(fault = '', nativeMode = false) {
|
||||
if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops })
|
||||
if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1,
|
||||
evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) })
|
||||
if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return response({ ...data, cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), groups: groups(), arrange: { allowed: !saved }, arrangement: saved ? { dispatchNo: 'DS-OWN' } : null })
|
||||
if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) }
|
||||
if (path.endsWith('/pick') && body.recordVersion !== recordVersion) return response(null, '公共版本已变化')
|
||||
assert.equal(body.recordVersion, recordVersion, raw)
|
||||
@@ -97,32 +95,21 @@ function fixture(fault = '', nativeMode = false) {
|
||||
if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) }
|
||||
if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) }
|
||||
if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) }
|
||||
if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'AFTER_LOADING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
assert.ok(path.endsWith('/transfer-delivery'), raw); assert.equal(stage, 'AFTER_LOADING')
|
||||
if (body.cargoHash !== 'a'.repeat(64)) return response(null, '装车或实际货物已经变化')
|
||||
if (body.groups.some(g => g.feeCoverage.some(s => s.sourceVersion !== 1))) return response(null, '原费用版本已变化')
|
||||
if (body.groups.some(g => g.externalFreightHash !== 'b'.repeat(64))) return response(null, '外接实收或约定运费已变化')
|
||||
if (body.groups.some(g => new Set(g.fees.map(f => `${f.direction}:${f.feeType}`)).size !== g.fees.length)) return response(null, '同组同方向同费用不得重复登记')
|
||||
if (body.groups.some(g => g.groupKey === 'G-PLAN:23' && g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收')
|
||||
if (!body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对')
|
||||
saved = clone(body); recordVersion++
|
||||
if (fault === 'unknown-arrange') return { http: 0, body: null }
|
||||
return remember(ack('ARRANGE_DELIVERY', { dispatchNo: 'DS-OWN', cargoHash: 'a'.repeat(64), outbounds: [...new Set(items.map(i => i.customerId))].map(customerId => ({ customerId, outboundNo: customerId === OWN ? 'OUT-OWN' : 'OUT-EXT', status: 'WAIT_OUTBOUND', packageCount: items.filter(i => i.customerId === customerId).reduce((n, i) => n + i.scheduledPackages, 0) })),
|
||||
deliveries: groups().map((g, n) => ({ groupKey: g.groupKey, customerId: g.customerId, destination: g.destination, outboundNo: g.customerId === EXT ? 'OUT-EXT' : 'OUT-OWN', deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY', packageCount: g.cargo.reduce((n, c) => n + c.packages, 0) })) }))
|
||||
if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
throw Error(`Unexpected ${method} ${raw}`)
|
||||
} }
|
||||
const options = nativeMode ? { pickerToken: 'OFFLINE-ONLY', arrangement: { mode: 'NATIVE', marker: 'CURRENT', dispatchNo: 'DS-OWN', plannedDeliveryAt: '2026-10-22T01:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', appointmentAccount: 'SELF',
|
||||
groups: [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户A明确派送费', currency: 'USD', amount: 11 }] }, { customerId: EXT, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户B明确派送费', currency: 'CNY', amount: 17 }] }] } } : { coverageCase: currentCase, pickerToken: 'OFFLINE-ONLY' }
|
||||
const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'}
|
||||
return { context, tripProof, currentCase, calls, assertions, items, options }
|
||||
}
|
||||
|
||||
test('native external flow adds actual received cargo to same whole, re-reviews, scans, loads and arranges all customer groups', async () => {
|
||||
const f = fixture(), proof = await verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.dispatchNo, 'DS-OWN'); assert.equal(proof.sourceFees.length, 1); assert.equal(proof.sourceFees[0].chargeableQuantity, 2)
|
||||
assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.newReceivable, 17.5)
|
||||
assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.feeCoverageSelection,'NOT_APPLICABLE_AUTOMATIC_DELIVERY')
|
||||
assert.ok(f.assertions.every(a => a.passed)); assert.equal(f.calls.some(c => /pre-arrange|dispatches\/bind|dispatches\/unbind|\/cancel/.test(c.path)), false)
|
||||
assert.equal(f.calls.filter(c => c.method === 'POST' && c.path === BASE).length, 0)
|
||||
assert.ok(f.calls.findIndex(c => c.path.endsWith('/return-to-review')) < f.calls.findIndex(c => c.path.endsWith('/items') && c.method === 'POST'))
|
||||
assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery')))
|
||||
assert.equal(f.calls.some(c => c.path.endsWith('/transfer-delivery')||c.path.endsWith('/depart')),false)
|
||||
assert.equal(f.items.length, 3); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /尚未装入/)
|
||||
})
|
||||
|
||||
@@ -133,8 +120,8 @@ for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'revie
|
||||
})
|
||||
|
||||
test('no external callback injection can replace real registered PARTIAL sources, current PDA or durable journal', async () => {
|
||||
for (const mutate of [f => { delete f.options.coverageCase }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) {
|
||||
const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /同主单未扫码PARTIAL/); assert.equal(f.calls.length, 0)
|
||||
for (const mutate of [f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) {
|
||||
const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)); assert.equal(f.calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -145,52 +132,14 @@ test('server binding requires the same existing whole dispatch and public cargo
|
||||
assert.equal(source.includes('/pre-arrange'), false); assert.equal(source.includes('draftToken'), false); assert.ok(source.includes('/return-to-review')); assert.ok(source.includes("'ADD_CARGO'"))
|
||||
})
|
||||
|
||||
test('ordinary native mode accepts two WAREHOUSE_TRANSFER sources and same-customer external cargo without FULL/PARTIAL fixtures', async () => {
|
||||
const f = fixture('', true), proof = await verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.arrangementMode, 'NATIVE'); assert.equal(proof.nativeArrangement.status, 'VERIFIED')
|
||||
assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false); assert.equal(f.currentCase.proof, undefined)
|
||||
assert.equal(proof.deliveries.length, 2); assert.equal(proof.deliveries.reduce((n, d) => n + d.packageCount, 0), 3)
|
||||
assert.ok(f.items.some(i => i.inboundCode === 'WT-OWN-1') && f.items.some(i => i.inboundCode === 'WT-OWN-2')); assert.equal(f.items.length, 3)
|
||||
const requests = f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')), actual = requests.at(-1)
|
||||
assert.deepEqual(proof.transferRequest.groups.map(g => g.fees.map(f => [f.currency, f.amount])), [[['USD', 11]], [['CNY', 17]]])
|
||||
assert.ok(proof.transferRequest.groups.every(g => g.feeCoverage.length === 0 && !g.groupKey.startsWith('FBA:')))
|
||||
assert.equal(requests.length, 5); assert.equal(actual.body.idempotencyKey, proof.transferRequest.idempotencyKey)
|
||||
assert.ok(f.calls.findIndex(c => c.path.endsWith('/pick')) < f.calls.findIndex(c => c.path.endsWith('/loading-complete')))
|
||||
assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery')))
|
||||
assert.ok(f.calls.every(c => !c.path.includes('DS-FULL') && !c.path.includes('pre-arrange'))); assert.ok(f.assertions.every(a => a.passed))
|
||||
const snapshots = proof.nativeArrangement.snapshots
|
||||
assert.deepEqual(snapshots['DEL-0'].fees.map(f => [f.currency, f.amount]), [['USD', 11], ['CNY', 30]])
|
||||
assert.deepEqual(snapshots['DEL-1'].fees.map(f => [f.currency, f.amount]), [['CNY', 17]])
|
||||
assert.equal(proof.sourceFees[0].originalAmount, 30); assert.equal(proof.sourceFees[0].customerId, OWN)
|
||||
test('ordinary current sources automatically deliver without manual fee configuration',async()=>{
|
||||
const f=fixture('',true),proof=await verifyFleetExternalDispatch(f.context,'external-native',f.tripProof,f.options)
|
||||
assert.equal(proof.status,'VERIFIED');assert.equal(proof.fulfillmentMode,'AUTOMATIC_DELIVERY')
|
||||
assert.equal(proof.deliveries.length,2);assert.equal(proof.deliveries.reduce((n,d)=>n+d.packageCount,0),3)
|
||||
assert.ok(proof.deliveries.every(d=>d.status==='DELIVERING'));assert.ok(proof.loadingRequest.idempotencyKey)
|
||||
assert.equal(f.calls.some(c=>c.path.endsWith('/transfer-delivery')||c.path.endsWith('/depart')),false)
|
||||
assert.equal(proof.sourceFees[0].originalAmount,30)
|
||||
})
|
||||
|
||||
test('ordinary native configuration and coverage mode are exclusive and require current explicit group configuration', async () => {
|
||||
for (const mutate of [f => { delete f.options.arrangement }, f => { f.options.coverageCase = f.currentCase }, f => { f.options.arrangement.mode = 'SAME_ROUTE' },
|
||||
f => { f.options.arrangement.marker = 'OLD' }, f => { f.options.arrangement.dispatchNo = 'OTHER' }, f => { f.options.arrangement.groups.push(clone(f.options.arrangement.groups[0])) },
|
||||
f => { f.options.arrangement.groups = async () => ({ success: true }) }, f => { f.options.arrangement.groups[0].fees[0].amount = 0 }, f => { f.options.arrangement.groups[0].fees.push(clone(f.options.arrangement.groups[0].fees[0])) },
|
||||
f => { f.options.arrangement.groups[0].feeCoverage = [] }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) {
|
||||
const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.equal(f.calls.length, 0)
|
||||
}
|
||||
for (const mutate of [f => { f.options.arrangement.groups.pop() }, f => { f.options.arrangement.groups[0].customerId = '999' }]) {
|
||||
const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options), /加货前精确覆盖/); assert.equal(f.calls.some(c => c.method !== 'GET'), false)
|
||||
}
|
||||
})
|
||||
|
||||
for (const fault of ['foreign-customer', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'already-loaded', 'missing-measurements', 'unknown-arrange']) test(`ordinary native flow preserves protection against ${fault}`, async () => {
|
||||
const f = fixture(fault, true); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED')
|
||||
assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false)
|
||||
if (fault === 'missing-measurements') assert.equal(f.calls.some(c => c.path.endsWith('/loading-complete') || c.path.endsWith('/transfer-delivery')), false)
|
||||
if (fault === 'unknown-arrange') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/transfer-delivery'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true); assert.ok(f.tripProof.externalDispatch.nativeArrangement.confirmationRequest.idempotencyKey) }
|
||||
})
|
||||
|
||||
test('ordinary native plan permits explicit zero extra fee only for fully quoted cargo and never expands the quote into each group', () => {
|
||||
const f = fixture('', true), receipt = f.tripProof.externalReceipt, plan = clone(f.options.arrangement)
|
||||
plan.groups = [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [] }]
|
||||
const group = { groupKey: 'REAL-GROUP', customerId: OWN, destination: { key: 'FBA:A1' }, groupCargoHash: 'a'.repeat(64), externalFreightHash: 'b'.repeat(64), feeCoverageCargoHash: 'a'.repeat(64), blockReasons: [], requiredServices: [], feeSources: [],
|
||||
cargo: [{ customerId: OWN, inboundCode: receipt.incomingNo, destination: { key: 'FBA:A1' } }], externalQuotes: [{ groupKey: 'REAL-GROUP', customerId: OWN, bookingNo: receipt.bookingNo, inboundCode: receipt.incomingNo, currency: 'CNY', amount: 30, sourceHash: 'b'.repeat(64) }] }
|
||||
const groups = nativeArrangementGroups({ groups: [group] }, plan, receipt)
|
||||
assert.deepEqual(groups[0].fees, []); assert.deepEqual(groups[0].feeCoverage, []); assert.equal(groups[0].groupKey, 'REAL-GROUP')
|
||||
plan.groups[0].fees = [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: 'duplicate', currency: 'CNY', amount: 30 }]
|
||||
assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /不得重复登记/)
|
||||
plan.groups[0].fees = []; group.requiredServices = ['DELIVERY_FEE']; assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /尚未覆盖/)
|
||||
for(const fault of ['foreign-customer','double-capacity','review-not-applied','stale-price','duplicate-fee','foreign-source-fee','wrong-quantity','replay-row-drift','already-loaded','missing-measurements'])test(`automatic external flow rejects ${fault}`,async()=>{
|
||||
const f=fixture(fault,true);await assert.rejects(verifyFleetExternalDispatch(f.context,'external-native',f.tripProof,f.options));assert.notEqual(f.tripProof.externalDispatch.status,'VERIFIED')
|
||||
})
|
||||
|
||||
@@ -1,141 +1,19 @@
|
||||
import { DISPATCH_BASE as BASE, id as stringId, version, createBody, candidatePage, readDispatch, replay } from './grouped-dispatch-acceptance.mjs'
|
||||
import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs'
|
||||
import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage, readAutomaticDelivery } from './fleet-fee-coverage-acceptance.mjs'
|
||||
|
||||
const clone = value => structuredClone(value)
|
||||
const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) }
|
||||
const money = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('外接约定金额或单价不完整'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) }
|
||||
const identity = item => ({ dispatchItemId: id(item.dispatchItemId), sourceKey: item.sourceKey, customerId: id(item.customerId), warehousePalletId: id(item.warehousePalletId), scheduledPackages: Number(item.scheduledPackages), pickedPackages: Number(item.pickedPackages) })
|
||||
const groupIdentity = (customerId, destinationKey) => JSON.stringify([id(customerId), destinationKey])
|
||||
const sha = value => /^[a-f0-9]{64}$/i.test(value || '')
|
||||
const feeIdentity = fee => `${fee.direction}:${fee.feeType}:${fee.currency}:${money(fee.amount)}`
|
||||
function validateNativeArrangement(context, tripProof, plan) {
|
||||
if (plan?.mode !== 'NATIVE' || plan.marker !== context.marker || plan.dispatchNo !== tripProof.dispatchNo
|
||||
|| typeof plan.plannedDeliveryAt !== 'string' || !Number.isFinite(Date.parse(plan.plannedDeliveryAt))
|
||||
|| !plan.deliveryMethod || !plan.pickupMethod || plan.loadingType !== 'PALLET' || plan.appointmentAccount !== 'SELF'
|
||||
|| !Array.isArray(plan.groups) || !plan.groups.length) throw Error('普通原生安排须明确本轮主单、派送时间、PALLET/SELF及逐客户目的地费用')
|
||||
const keys = new Set()
|
||||
for (const group of plan.groups) {
|
||||
const key = groupIdentity(group.customerId, group.destinationKey)
|
||||
if (!/^[1-9]\d*$/.test(id(group.customerId)) || typeof group.destinationKey !== 'string' || !group.destinationKey.trim() || keys.has(key) || !Array.isArray(group.fees) || Object.hasOwn(group, 'feeCoverage')) throw Error('普通安排的客户目的地配置缺失、重复或含原费覆盖选择')
|
||||
keys.add(key)
|
||||
const fees = new Set()
|
||||
for (const fee of group.fees) {
|
||||
const feeKey = `${fee.direction}:${fee.feeType}`
|
||||
if (!['RECEIVABLE', 'PAYABLE'].includes(fee.direction) || !['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].includes(fee.feeType)
|
||||
|| !fee.feeName || !['CNY', 'USD'].includes(fee.currency) || money(fee.amount) <= 0n || fees.has(feeKey)) throw Error('普通安排费用必须逐组明确正数、币种和服务,不得重复')
|
||||
fees.add(feeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 仅将明确的客户/目的地配置映射到实时groupKey与摘要;不推算费用或原费覆盖。
|
||||
export function nativeArrangementGroups(preview, plan, receipt) {
|
||||
const configured = new Map(plan.groups.map(g => [groupIdentity(g.customerId, g.destinationKey), g]))
|
||||
if (!Array.isArray(preview.groups) || preview.groups.length !== configured.size || new Set(preview.groups.map(g => groupIdentity(g.customerId, g.destination?.key))).size !== configured.size) throw Error('普通安排配置必须精确覆盖全车全部真实客户目的地')
|
||||
return preview.groups.map(group => {
|
||||
const config = configured.get(groupIdentity(group.customerId, group.destination?.key))
|
||||
if (!config || !group.groupKey || !sha(group.groupCargoHash) || !sha(group.externalFreightHash) || !sha(group.feeCoverageCargoHash)
|
||||
|| !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.requiredServices) || !Array.isArray(group.externalQuotes)
|
||||
|| !Array.isArray(group.feeSources) || !Array.isArray(group.cargo) || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('普通原生安排的实际分组或来源门禁不完整')
|
||||
if (group.externalQuotes.some(q => q.bookingNo !== receipt.bookingNo || q.inboundCode !== receipt.incomingNo || id(q.customerId) !== id(group.customerId)
|
||||
|| q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash || q.currency !== receipt.finalBooking.cargo.currency || money(q.amount) !== money(receipt.finalBooking.agreedAmount))) throw Error('普通安排的外接报价已变化或越过客户来源')
|
||||
if (group.requiredServices.some(service => !config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === service))) throw Error('普通安排必须明确填写本组尚未覆盖的服务费用')
|
||||
const quotedAll = group.cargo.every(c => group.externalQuotes.some(q => q.inboundCode === c.inboundCode))
|
||||
if (quotedAll && config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE')) throw Error('本组派送已由外接约定覆盖,不得重复登记应收')
|
||||
return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: plan.plannedDeliveryAt,
|
||||
deliveryMethod: plan.deliveryMethod, pickupMethod: plan.pickupMethod, loadingType: plan.loadingType, appointmentAccount: plan.appointmentAccount,
|
||||
waybillNo: plan.waybillNo || null, carrierName: plan.carrierName || null, vehicleType: plan.vehicleType || null, vehicleSize: plan.vehicleSize || null, sellerName: null, remark: plan.remark || null,
|
||||
externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: clone(config.fees) }
|
||||
})
|
||||
}
|
||||
|
||||
async function arrangeNative(context, module, io, proof, tripProof, plan, pickerToken, actualMeasurements) {
|
||||
const current = await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements })
|
||||
const path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`, preview = await io.get(`${path}/delivery-arrangement`, '普通模式读取装车后实时全车派送分组')
|
||||
io.check('普通安排使用当前公共版本和真实实载摘要', null, preview?.dispatchNo === tripProof.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING'
|
||||
&& id(version(preview.recordVersion)) === id(version(current.data.recordVersion)) && preview.arrange?.allowed === true && !preview.arrangement && sha(preview.cargoHash) && sha(preview.loadingCompletionHash))
|
||||
const groups = nativeArrangementGroups(preview, plan, tripProof.externalReceipt), totals = new Map()
|
||||
for (const group of preview.groups) for (const row of group.cargo) {
|
||||
io.check('普通派送货物保留实际客户目的地和原物理行', null, current.items.items.some(i => id(i.dispatchItemId) === id(row.dispatchItemId) && id(i.customerId) === id(group.customerId) && i.destination.key === group.destination.key && id(i.warehousePalletId) === id(row.warehousePalletId)))
|
||||
totals.set(id(row.dispatchItemId), (totals.get(id(row.dispatchItemId)) || 0) + Number(row.packages))
|
||||
}
|
||||
io.check('普通安排逐物理行箱数完整守恒', null, totals.size === current.items.items.length && current.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages)))
|
||||
const body = { recordVersion: version(preview.recordVersion), idempotencyKey: io.key('native-arrangement'), cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups }
|
||||
proof.nativeArrangement = { mode: 'NATIVE', confirmationRequest: clone(body), status: 'CHECKING' }; await context.checkpoint()
|
||||
const noDocuments = async () => {
|
||||
for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) {
|
||||
const rows = []; let total
|
||||
for (let page = 1; ; page++) {
|
||||
const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId, page: String(page), size: '200' })}`, '普通安排拒绝前后按客户回查无出库写入')
|
||||
io.check('普通安排出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 && Number(data.page) === page && Number(data.size) === 200
|
||||
&& (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total)))
|
||||
total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break
|
||||
}
|
||||
io.check('普通安排拒绝未生成本主单出库', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== tripProof.dispatchNo))
|
||||
}
|
||||
}
|
||||
const reject = async (action, changed, pattern) => {
|
||||
const before = await readDispatch(io, tripProof.dispatchNo)
|
||||
await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: io.key(action) }, action, pattern)
|
||||
await noDocuments(); io.check(`${action}不改整车版本及实物`, null, JSON.stringify(await readDispatch(io, tripProof.dispatchNo)) === JSON.stringify(before))
|
||||
}
|
||||
await noDocuments()
|
||||
const changedHash = hash => `${hash[0] === '0' ? '1' : '0'}${hash.slice(1)}`
|
||||
await reject('普通安排陈旧货物摘要拒绝', { cargoHash: changedHash(body.cargoHash) }, /货物.*变化/)
|
||||
const quoted = preview.groups.find(g => g.externalQuotes.length)
|
||||
io.check('普通安排包含本轮已确认外接报价', null, Boolean(quoted))
|
||||
const stale = clone(groups); stale.find(g => g.groupKey === quoted.groupKey).externalFreightHash = changedHash(quoted.externalFreightHash)
|
||||
await reject('普通安排陈旧外接报价摘要拒绝', { groups: stale }, /外接.*变化/)
|
||||
const duplicate = clone(groups), quote = quoted.externalQuotes[0]
|
||||
duplicate.find(g => g.groupKey === quoted.groupKey).fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接应收反例', currency: quote.currency, amount: quote.amount })
|
||||
await reject('普通安排重复外接应收拒绝', { groups: duplicate }, /重复|覆盖/)
|
||||
const command = await io.request('POST', `${path}/transfer-delivery`, body, '普通原生模式一次安排全车全部客户目的地'), result = command?.result
|
||||
io.check('普通原生命令完整返回真实客户出库和目的地派送', null, command?.dispatchNo === tripProof.dispatchNo && command.action === 'ARRANGE_DELIVERY' && command.status === 'ACTIVE' && command.stage === 'AFTER_LOADING'
|
||||
&& BigInt(version(command.recordVersion)) > BigInt(version(body.recordVersion)) && result?.dispatchNo === tripProof.dispatchNo && result.cargoHash === body.cargoHash
|
||||
&& result.deliveries?.length === preview.groups.length && new Set(result.deliveries.map(d => d.deliveryNo)).size === result.deliveries.length
|
||||
&& result.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size
|
||||
&& result.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key
|
||||
&& g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) && result.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))
|
||||
&& result.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && Number(o.packageCount) === result.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0)))
|
||||
const snapshots = {}
|
||||
for (const ref of result.deliveries) {
|
||||
const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = groups.find(g => g.groupKey === ref.groupKey)
|
||||
const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排回查客户目的地及明确费用')
|
||||
const expectedFees = [...input.fees.map(feeIdentity), ...group.externalQuotes.filter(q => money(q.amount) > 0n).map(q => feeIdentity({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: q.currency, amount: q.amount }))]
|
||||
io.check('普通派送保留本客户本目的地费用,无伪零、重复或原费覆盖回执', null, detail?.order?.dispatchNo === tripProof.dispatchNo && detail.order.deliveryNo === ref.deliveryNo && id(detail.order.customerId) === id(ref.customerId)
|
||||
&& detail.order.outboundNo === ref.outboundNo && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId)
|
||||
&& (detail.feeCoverage == null || detail.feeCoverage.references?.length === 0) && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length
|
||||
&& detail.fees.every(f => money(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED')
|
||||
&& stableRows(detail.fees.map(f => feeIdentity({ direction: f.feeDirection, feeType: f.feeType, currency: f.currency, amount: f.amount }))) === stableRows(expectedFees))
|
||||
snapshots[ref.deliveryNo] = { fees: clone(detail.fees), feeCoverage: clone(detail.feeCoverage) }
|
||||
}
|
||||
const again = await replay(io, `${path}/transfer-delivery`, body, command, '普通安排原键原载荷重放'); io.check('普通安排明确恢复原成功命令', null, again.replayed === true)
|
||||
for (const ref of result.deliveries) {
|
||||
const after = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排重放后核对不追加应收')
|
||||
io.check('普通安排重放不增费不改覆盖快照', null, JSON.stringify({ fees: after.fees, feeCoverage: after.feeCoverage }) === JSON.stringify(snapshots[ref.deliveryNo]))
|
||||
}
|
||||
const after = await io.get(`${path}/delivery-arrangement`, '普通安排后回查报价与原费用候选未被改写')
|
||||
const originalFacts = value => stableRows(value.groups.map(g => ({ groupKey: g.groupKey, externalQuotes: g.externalQuotes, feeSources: g.feeSources })))
|
||||
io.check('普通安排保持报价及原费用来源快照', null, originalFacts(after) === originalFacts(preview))
|
||||
Object.assign(proof.nativeArrangement, { status: 'VERIFIED', command, snapshots })
|
||||
return { confirmationRequest: body, deliveries: result.deliveries, outbounds: result.outbounds }
|
||||
}
|
||||
|
||||
/** 已确认实收来源只ADD_CARGO到原整车;复审、真实扫码、显式装车后一次安排全部客户。
|
||||
* options二选一:coverageCase为本轮PARTIAL;或arrangement={mode:'NATIVE',marker,dispatchNo,
|
||||
* plannedDeliveryAt,deliveryMethod,pickupMethod,loadingType:'PALLET',appointmentAccount:'SELF',
|
||||
* groups:[{customerId,destinationKey,fees:FeeItem[]}]}; 金额和费用覆盖均不自动推断。
|
||||
*/
|
||||
export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase, arrangement } = {}) {
|
||||
/** 将本轮已实收外接货加入原车,复审及真实装车后读取自动派送。 */
|
||||
export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase } = {}) {
|
||||
const receipt = tripProof?.externalReceipt, cases = context.createdResources?.fleetFeeCoverageCases
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch)
|
||||
throw Error('外接只接受本轮已运营确认且尚未装入本车的真实来货')
|
||||
if (Boolean(coverageCase) === Boolean(arrangement)) throw Error('必须且只能选择同主单未扫码PARTIAL费用覆盖或普通NATIVE安排模式')
|
||||
if (arrangement) validateNativeArrangement(context, tripProof, arrangement)
|
||||
if (coverageCase && (!cases?.includes(coverageCase) || coverageCase.marker !== context.marker || coverageCase.mode !== 'PARTIAL'
|
||||
|| coverageCase.dispatchNo !== tripProof.dispatchNo || coverageCase.proof) || !pickerToken || typeof context.checkpoint !== 'function')
|
||||
throw Error('外接加货前须有同主单未扫码PARTIAL用例及本轮PDA身份;必须先外接后扫码安排')
|
||||
const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, arrangementMode: arrangement ? 'NATIVE' : 'FEE_COVERAGE', status: 'CHECKING', requests: [] }
|
||||
const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, fulfillmentMode: 'AUTOMATIC_DELIVERY', status: 'CHECKING', requests: [] }
|
||||
const io = fleetConsumerIo(context, module, proof, `EXTERNAL-${tripProof.dispatchNo}`)
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`
|
||||
const commandReplay = async (path, body, result, label) => { const again = await replay(io, path, body, result, label); io.check(`${label}明确恢复原结果`, null, again.replayed === true) }
|
||||
@@ -163,10 +41,6 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, {
|
||||
io.check('候选仅为本次确认的全部真实外接来源', null, candidates.length === actual.length && candidates.every(c => c.destination.key === booking.cargo.destinationKey
|
||||
&& actual.some(p => id(p.warehousePalletId) === id(c.selection.warehousePalletId) && p.palletNo === c.palletNo && Number(p.packages) === Number(c.availablePackages))))
|
||||
const addition = createBody(context.marker, `EXTERNAL-${tripProof.dispatchNo}`, candidates)
|
||||
if (arrangement) {
|
||||
const actualGroups = new Set([...current.items.items.map(i => groupIdentity(i.customerId, i.destination?.key)), ...candidates.map(c => groupIdentity(c.customerId, c.destination?.key))])
|
||||
io.check('普通安排配置在加货前精确覆盖全部实际客户目的地', null, stableRows([...actualGroups]) === stableRows(arrangement.groups.map(g => groupIdentity(g.customerId, g.destinationKey))))
|
||||
}
|
||||
if (current.data.stage === 'LOADING') {
|
||||
proof.returnRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('explicit-return'), reason: '本轮零扫码外接实收显式退回装车前,保留同一整车' }
|
||||
const returned = await io.request('POST', `${path}/return-to-review`, proof.returnRequest, '零扫码明确退回审核')
|
||||
@@ -218,14 +92,10 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, {
|
||||
const ready = await io.get(path, '核对两级审核恢复真实扫码资格')
|
||||
io.check('同主单两级审核通过且仍零扫码', null, ready.stage === 'LOADING' && ready.reviewState === 'APPROVED' && ready.reviews?.dispatchReview?.status === 'APPROVED'
|
||||
&& ready.reviews?.loadingReview?.status === 'APPROVED' && Number(ready.counts.pickedPackageCount) === 0)
|
||||
const execution = arrangement ? await arrangeNative(context, module, io, proof, tripProof, arrangement, pickerToken, actualMeasurements)
|
||||
: await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof })
|
||||
const groups = await io.get(`${path}/delivery-arrangement`, '安排后回查原生外接报价快照')
|
||||
const quotes = groups.groups.flatMap(g => g.externalQuotes), expected = receipt.finalBooking
|
||||
const matching = quotes.filter(q => q.bookingNo === receipt.bookingNo && q.inboundCode === receipt.incomingNo && id(q.customerId) === id(booking.customerId))
|
||||
io.check('原外接报价属于本次客户和来源且等于运营确认约定金额', null, matching.length === 1 && money(matching[0].amount) === money(expected.agreedAmount)
|
||||
&& matching[0].currency === expected.cargo.currency && matching[0].sourceHash && money(expected.agreedAmount) > 0n)
|
||||
const deliveries = execution.deliveries.filter(d => groups.groups.some(g => g.groupKey === d.groupKey && g.externalQuotes.some(q => q.bookingNo === receipt.bookingNo)))
|
||||
const execution = coverageCase ? await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof })
|
||||
: await readAutomaticDelivery(io, await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }), tripProof)
|
||||
const expected = receipt.finalBooking
|
||||
const deliveries = execution.deliveries.filter(d => id(d.customerId) === id(booking.customerId) && d.destination?.key === booking.cargo.destinationKey)
|
||||
io.check('本次外接来源只落入其客户目的地派送', null, deliveries.length === 1 && id(deliveries[0].customerId) === id(booking.customerId))
|
||||
proof.outboundNo = deliveries[0].outboundNo; proof.deliveryNo = deliveries[0].deliveryNo; proof.deliveries = execution.deliveries
|
||||
const delivery = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(id(booking.customerId))}`, '回查本客户外接应收来源')
|
||||
@@ -236,8 +106,8 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, {
|
||||
&& fees[0].originalCurrency === expected.cargo.currency && fees[0].reviewStatus === 'APPROVED' && id(fees[0].customerId) === id(booking.customerId)
|
||||
&& quantity != null && Number(fees[0].chargeableQuantity) === Number(quantity) && money(fees[0].unitPrice) === money(expected.cargo.unitPrice)
|
||||
&& fees[0].quantityUnit === (expected.cargo.priceBasis === 'PALLET' ? '托位' : '单')
|
||||
&& fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id)))
|
||||
proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.transferRequest = execution.confirmationRequest
|
||||
&& fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id) && money(f.amount) === money(fees[0].originalAmount) && f.currency === fees[0].originalCurrency))
|
||||
proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.loadingRequest = execution.confirmationRequest
|
||||
proof.finalTripVersion = (await io.get(base, '交接同车资源版本')).trip.recordVersion
|
||||
proof.financeAcknowledgement = 'NOT_VERIFIED'; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof
|
||||
} catch (error) { proof.status = proof.outcomeUnknown || context.createdResources.dispatchWriteBoundary?.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error }
|
||||
|
||||
@@ -1,122 +1,39 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { verifyFleetFeeCoverage, coveragePlan } from './fleet-fee-coverage-acceptance.mjs'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', customerId = '900719925474099301'
|
||||
const clone = value => structuredClone(value)
|
||||
function fixture(mode = 'FULL', fault = '', needsLoading = false) {
|
||||
const cases = ['FULL', 'PARTIAL'].map((mode, index) => ({ marker: 'CURRENT-TEST', mode, dispatchNo: `DS-${index + 1}`, customerId,
|
||||
transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }] } }))
|
||||
const source = cases.find(c => c.mode === mode), calls = [], assertions = [], ledger = new Map(), saved = new Map()
|
||||
const states = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, recordVersion: 0, stage: needsLoading && c === source ? 'LOADING' : 'AFTER_LOADING', items: [1, 2].map(i => ({
|
||||
dispatchItemId: `${n + 1}1${i}`, sourceKey: `PLAN:${n + 1}2${i}`, sourceVersion: 'a'.repeat(64), customerId, warehousePalletId: `${n + 1}3${i}`, planPalletId: `${n + 1}2${i}`,
|
||||
inboundCode: `IN-${n + 1}-${i}`, destination: { key: `FBA:A${i}`, warehouseCode: `A${i}` }, scheduledPackages: 2, pickedPackages: needsLoading && c === source ? 0 : 2 })) }))
|
||||
if (fault === 'overlap') states[1].items[0].warehousePalletId = states[0].items[0].warehousePalletId
|
||||
const triples = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, tripNo: `TR-${n + 1}`, warehouseId: '92', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }] }))
|
||||
let dirty = false, replayed = false
|
||||
const totals = s => ({ itemCount: s.items.length, palletCount: s.items.length, packageCount: 4, pickedPackageCount: s.items.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 })
|
||||
const detail = s => ({ dispatchNo: s.dispatchNo, status: 'ACTIVE', stage: s.stage, recordVersion: s.recordVersion, counts: totals(s), pickingReconciled: s.items.every(i => i.pickedPackages === 2),
|
||||
dispatchInfo: { route: 'A1,A2' }, orderDetails: s.items.map(i => ({ customerId, sourceKey: i.sourceKey, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) })
|
||||
const cargo = s => s.items.map((i, n) => ({ ...i, orderId: `4${s.dispatchNo.slice(-1)}${n + 1}`, sourceRegion: 'FIRSTLEG', sourceInboundId: `9007199254740994${s.dispatchNo.slice(-1)}${n + 1}`, packages: 2 }))
|
||||
const groups = s => cargo(s).map((row, n) => ({ groupKey: `G-${s.dispatchNo}-${n}`, customerId, destination: row.destination, groupCargoHash: `${n + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), externalQuotes: [], feeCoverageCargoHash: `${n + 1}`.repeat(64), cargo: [row], blockReasons: [], requiredServices: ['DELIVERY_FEE'],
|
||||
feeSources: fault === 'missing-source' && n === 1 ? [] : [{ financeFeeId: `9007199254740995${s.dispatchNo.slice(-1)}${n}`, customerId: fault === 'foreign-customer' && n === 0 ? '999' : customerId,
|
||||
currency: 'CNY', amount: fault === 'mutated-original' && saved.has(s.dispatchNo) ? 201 : 200, sourceVersion: 1, sourceHash: `fee-hash-${n}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [row.sourceKey] }] }))
|
||||
const ack = (s, action, result = {}) => ({ dispatchNo: s.dispatchNo, action, recordVersion: s.recordVersion, status: 'ACTIVE', stage: s.stage, counts: totals(s), result, replayed: false })
|
||||
const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' })
|
||||
const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: cases, fleetTrips: triples }, checkpoint: async () => {},
|
||||
assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data },
|
||||
async request(method, raw, body, token) {
|
||||
calls.push({ method, path: raw, body: clone(body), token })
|
||||
const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), number = normalized.slice(BASE.length + 1).split('/')[0]
|
||||
const state = states.find(s => s.dispatchNo === number), commandKey = `${normalized}:${body?.idempotencyKey}`
|
||||
if (method === 'POST' && ledger.has(commandKey)) { assert.deepEqual(body, ledger.get(commandKey).body); replayed = true; return response({ ...clone(ledger.get(commandKey).data), replayed: true }) }
|
||||
const remember = data => { ledger.set(commandKey, { body: clone(body), data: clone(data) }); return response(data) }
|
||||
if (method === 'GET' && path === BASE) return response({ records: states.filter(s => s.dispatchNo === params.get('dispatchNo')).map(s => ({ ...detail(s), warehouseId: '92' })) })
|
||||
if (path.includes('/outbound-orders?') || raw.includes('/outbound-orders?')) return response({ records: dirty ? [{ outboundNo: 'OUT-DIRTY', dispatchNo: source.dispatchNo }] : [], total: dirty ? 1 : 0, page: 1, size: 200 })
|
||||
if (path.match(/\/outbound-orders\/OUT-DS-[12]$/)) { const n = path.slice(path.lastIndexOf('OUT-') + 4), s = states.find(s => s.dispatchNo === n), p = saved.get(n); return response({ outbound: { outboundNo: `OUT-${n}`, dispatchNo: n, status: 'WAIT_OUTBOUND' }, deliveries: p.groups.map((g, i) => ({ deliveryNo: `DEL-${n}-${i}`, status: 'WAIT_DELIVERY' })) }) }
|
||||
const delivery = path.match(/\/delivery-orders\/DEL-(DS-[12])-(\d)$/)
|
||||
if (delivery) {
|
||||
assert.equal(params.get('customerId'), customerId)
|
||||
const s = states.find(s => s.dispatchNo === delivery[1]), index = Number(delivery[2]), group = groups(s)[index], input = saved.get(s.dispatchNo).groups[index]
|
||||
const references = input.feeCoverage.map(selection => ({ source: { ...group.feeSources.find(f => f.financeFeeId === selection.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId,
|
||||
...(fault === 'snapshot-source-amount' ? { amount: 999 } : {}) }, services: selection.services }))
|
||||
const evidence = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: fault === 'wrong-cargo' ? cargo(s) : group.cargo,
|
||||
references: fault === 'missing-reference' ? [] : references, operatorId: 'OP', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-19T02:00:00Z' : '2026-10-19T01:00:00Z' } : null
|
||||
let fees = input.fees.map(f => ({ id: `8${index}`, feeDirection: f.direction, feeType: f.feeType, currency: f.currency, amount: f.amount, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }))
|
||||
if (fault === 'duplicate-new-fee' && index === 0 || fault === 'replay-adds-fee' && replayed) fees.push({ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' })
|
||||
if (fault === 'charge-covered-destination') fees = index === 0 ? [{ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }] : []
|
||||
return response({ order: { dispatchNo: s.dispatchNo, deliveryNo: `DEL-${s.dispatchNo}-${index}`, customerId: fault === 'wrong-delivery-customer' ? '999' : customerId, outboundNo: `OUT-${s.dispatchNo}`, destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage: evidence })
|
||||
}
|
||||
assert.ok(state, `${method} ${raw}`)
|
||||
if (path.startsWith(PDA) && method === 'GET' && normalized.endsWith(number)) return response({ ...detail(state), items: state.items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: 2 }] })) })
|
||||
if (method === 'POST' && normalized.endsWith('/pick')) { assert.ok(token); if (body.recordVersion !== state.recordVersion) return response(null, '公共版本已变化'); const itemId = normalized.split('/').at(-2); state.items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; state.recordVersion++; return remember(ack(state, 'PICK')) }
|
||||
if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...detail(state), manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: state.items.map((i, n) => ({ ...i, palletNo: `PAL-${n}`, deliveryIndex: n + 1, palletSpaces: 1, weightKg: 20, volumeCbm: 0.2,
|
||||
evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? 1 : field === 'weightKg' ? 20 : 0.2, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) })) })
|
||||
if (method === 'POST' && normalized.endsWith('/loading-complete')) { assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.items.every(i => i.pickedPackages === 2), true); state.stage = 'AFTER_LOADING'; state.recordVersion++; return remember(ack(state, 'LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
if (method === 'GET' && path.endsWith('/items')) return response({ dispatchNo: number, recordVersion: state.recordVersion, status: 'ACTIVE', stage: state.stage, items: state.items })
|
||||
if (method === 'GET' && path.endsWith('/route')) return response({ dispatchNo: number, recordVersion: state.recordVersion, stops: triples.find(t => t.dispatchNo === number).stops })
|
||||
if (method === 'GET' && path.endsWith('/delivery-arrangement')) return response({ ...detail(state), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), arrange: { allowed: !saved.has(number) }, groups: groups(state), arrangement: saved.has(number) ? { dispatchNo: number } : null })
|
||||
if (method === 'POST' && path.endsWith('/transfer-delivery')) {
|
||||
assert.ok(body.idempotencyKey); assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.stage, 'AFTER_LOADING')
|
||||
if (body.cargoHash !== 'a'.repeat(64)) { if (fault === 'failed-command-writes') dirty = true; return response(null, '装车或实际货物已经变化') }
|
||||
if (body.groups.some(g => g.feeCoverage.some(f => f.sourceVersion !== 1))) return response(null, '原费用版本已变化')
|
||||
if (source.mode === 'FULL' && body.groups.some(g => g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收')
|
||||
if (source.mode === 'PARTIAL' && !body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对')
|
||||
saved.set(number, clone(body)); state.recordVersion++
|
||||
const result = ack(state, 'ARRANGE_DELIVERY', { dispatchNo: number, cargoHash: body.cargoHash, outbounds: [{ customerId, outboundNo: `OUT-${number}`, status: 'WAIT_OUTBOUND', packageCount: 4 }],
|
||||
deliveries: groups(state).map((g, n) => ({ groupKey: g.groupKey, customerId, destination: g.destination, outboundNo: `OUT-${number}`, deliveryNo: `DEL-${number}-${n}`, status: 'WAIT_DELIVERY', packageCount: 2 })) })
|
||||
if (fault === 'unknown-write') return { http: 0, body: null, raw: 'connection reset' }
|
||||
return remember(result)
|
||||
}
|
||||
assert.equal(method, 'GET', raw); assert.equal(path, `${BASE}/${number}`); return response(detail(state))
|
||||
} }
|
||||
return { context, source, cases, calls, assertions, states, groups, clearFault() { fault = '' } }
|
||||
import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs'
|
||||
const BASE = '/api/platform/v1/last-leg/dispatches'
|
||||
function fixture(fault = '') {
|
||||
const cases = ['FULL','PARTIAL'].map((mode,i)=>({marker:'RUN',mode,dispatchNo:`DS-${i}`,customerId:'C'}))
|
||||
const trips = cases.map(c=>({dispatchNo:c.dispatchNo,tripNo:`TR-${c.mode}`,warehouseId:'92',loadingCommand:{body:{idempotencyKey:`KEY-${c.mode}`},result:{action:'LOADING_COMPLETE',dispatchNo:c.dispatchNo,stage:'DELIVERING',result:{loadingCompletionHash:'HASH'}}}}))
|
||||
const items = cases.map((c,i)=>[{customerId:'C',warehousePalletId:fault==='overlap'?'PAL':`PAL-${i}`,sourceKey:`SOURCE-${i}`,destination:{key:'FBA:A'},pickedPackages:2,scheduledPackages:2}])
|
||||
const docs = i=>({outbounds:[{customerId:'C',outboundNo:`OUT-${i}`,status:'OUTBOUNDED'}],deliveries:[{customerId:'C',outboundNo:`OUT-${i}`,deliveryNo:`DEL-${i}`,destination:{key:'FBA:A'},status:'DELIVERING',packageCount:2}]})
|
||||
const detail = i=>({dispatchNo:`DS-${i}`,recordVersion:7,status:'ACTIVE',stage:'DELIVERING',pickingReconciled:true,counts:{packageCount:2,pickedPackageCount:2,shippedPackageCount:2},documents:fault==='missing-docs'?{}:docs(i)})
|
||||
const calls=[]
|
||||
const context={marker:'RUN',createdResources:{fleetFeeCoverageCases:cases,fleetTrips:trips},checkpoint:async()=>{},assert(_m,label,_r,ok){assert.ok(ok,label)},apiSuccess(_m,_l,r){return r.body.data},async request(method,raw){
|
||||
calls.push({method,raw});assert.equal(method,'GET')
|
||||
const url=new URL(raw,'https://offline.invalid'),path=url.pathname
|
||||
let data
|
||||
if(path===BASE){const i=Number(url.searchParams.get('dispatchNo').slice(-1));data={records:[{...detail(i),warehouseId:'92'}]}}
|
||||
else if(path.includes('/delivery-orders/')){const i=Number(path.slice(-1));assert.equal(url.searchParams.get('customerId'),'C');data={order:{dispatchNo:`DS-${i}`,deliveryNo:`DEL-${i}`,outboundNo:`OUT-${i}`,customerId:fault==='foreign-customer'?'OTHER':'C',packageCount:2,status:'DELIVERING'},fees:fault==='duplicate-fee'?[{id:'F'},{id:'F'}]:[],feeCoverage:null}}
|
||||
else{const i=Number(path.split('/dispatches/')[1][3]);data=path.endsWith('/items')?{...detail(i),items:items[i]}:detail(i)}
|
||||
return {http:200,body:{code:'200',data},requestId:'REQ',traceId:'TRACE'}
|
||||
}}
|
||||
return {context,cases,trips,calls}
|
||||
}
|
||||
|
||||
for (const mode of ['FULL', 'PARTIAL']) test(`${mode} uses native grouped fees, immutable confirmation evidence and original-key replay`, async () => {
|
||||
const f = fixture(mode), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source)
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5); assert.equal(proof.deliveryNos.length, 2)
|
||||
assert.ok(f.assertions.every(a => a.passed)); assert.ok(f.calls.every(c => !/cancel|unbind|pre-arrange|fee-coverage/.test(c.path)))
|
||||
assert.equal(proof.confirmationRequest.recordVersion, 0); assert.equal(proof.confirmationRequest.groups.length, 2)
|
||||
await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/)
|
||||
for(const index of [0,1])test(`automatic delivery reads authoritative documents for fixture ${index}`,async()=>{
|
||||
const f=fixture(),proof=await verifyFleetFeeCoverage(f.context,'automatic',f.cases[index])
|
||||
assert.equal(proof.status,'VERIFIED');assert.deepEqual(proof.deliveryNos,[`DEL-${index}`])
|
||||
assert.equal(proof.feeCoverageSelection,'NOT_APPLICABLE_AUTOMATIC_DELIVERY')
|
||||
assert.equal(Object.hasOwn(proof,'newReceivable'),false)
|
||||
assert.deepEqual(proof.confirmationRequest,f.trips[index].loadingCommand.body)
|
||||
assert.ok(f.calls.every(c=>c.method==='GET'))
|
||||
assert.equal((await verifyFleetFeeCoverage(f.context,'automatic',f.cases[index],{reconcilePersisted:true})).status,'VERIFIED')
|
||||
await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[index]),/未执行/)
|
||||
})
|
||||
|
||||
test('LOADING performs actual PDA picks, immutable pick replay and explicit loading completion before arranging', async () => {
|
||||
const f = fixture('FULL', '', true), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { pickerToken: 'OFFLINE-ONLY' })
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
const loading = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/loading-complete')), arrangement = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery'))
|
||||
assert.ok(loading > f.calls.findIndex(c => c.path.endsWith('/pick'))); assert.ok(arrangement > loading)
|
||||
for(const fault of ['overlap','missing-docs','foreign-customer','duplicate-fee'])test(`automatic proof rejects ${fault}`,async()=>{
|
||||
const f=fixture(fault);await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[0]));assert.notEqual(f.cases[0].proof.status,'VERIFIED')
|
||||
})
|
||||
|
||||
test('reviewed persisted reconciliation only replays original successful native command after actual document readback', async () => {
|
||||
const f = fixture('FULL', 'wrong-cargo'); await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.ok(f.source.proof.outboundNo)
|
||||
const original = clone(f.source.proof.confirmationRequest), before = f.calls.length; f.clearFault()
|
||||
assert.equal((await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })).status, 'VERIFIED')
|
||||
assert.ok(f.calls.slice(before).filter(c => c.method === 'POST').every(c => JSON.stringify(c.body) === JSON.stringify(original)))
|
||||
})
|
||||
|
||||
for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee', 'wrong-delivery-customer', 'overlap', 'unknown-write', 'snapshot-source-amount']) test(`native fee proof rejects ${fault}`, async () => {
|
||||
const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault)
|
||||
await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.notEqual(f.source.proof.status, 'VERIFIED')
|
||||
if (['foreign-customer', 'missing-source', 'overlap'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false)
|
||||
if (fault === 'unknown-write') { assert.equal(f.source.proof.status, 'OUTCOME_UNKNOWN'); const last = f.calls.at(-1); assert.equal(last.method, 'POST'); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) }
|
||||
})
|
||||
|
||||
test('two current registered source groups are required before HTTP; missing PDA and ambiguous mixed provenance never guess', async () => {
|
||||
const missing = fixture(); missing.context.createdResources.fleetFeeCoverageCases.pop(); await assert.rejects(verifyFleetFeeCoverage(missing.context, 'coverage', missing.source), /两组不重叠/); assert.equal(missing.calls.length, 0)
|
||||
const noPicker = fixture('FULL', '', true); await assert.rejects(verifyFleetFeeCoverage(noPicker.context, 'coverage', noPicker.source), /PDA身份/); assert.equal(noPicker.calls.some(c => c.method === 'POST'), false)
|
||||
const f = fixture('PARTIAL'), groups = f.groups(f.states[1]); groups[0].cargo.push({ ...groups[0].cargo[0], sourceInboundId: '999' }); assert.throws(() => coveragePlan({ groups }, f.source), /不能唯一映射/)
|
||||
const multi = fixture('PARTIAL'), many = multi.groups(multi.states[1]), third = clone(many[1]); third.groupKey = 'GROUP-THIRD'; third.cargo[0].sourceKey = 'PLAN:999'; third.cargo[0].sourceInboundId = '999'; third.feeSources[0].financeFeeId = '9999'; third.feeSources[0].sourceKeys = ['PLAN:999']; many.push(third)
|
||||
assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/)
|
||||
multi.source.transferBody.groupFees = { [many[1].groupKey]: [{ ...multi.source.transferBody.fees[0], amount: 6 }] }
|
||||
assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/)
|
||||
multi.source.transferBody.groupFees[third.groupKey] = [{ ...multi.source.transferBody.fees[0], amount: 7 }]
|
||||
assert.deepEqual(coveragePlan({ groups: many }, multi.source).groups.map(g => g.fees.map(f => f.amount)), [[], [6], [7]])
|
||||
const unsafe = fixture(); unsafe.source.customerId = 9007199254740992; assert.throws(() => coveragePlan({ groups: unsafe.groups(unsafe.states[0]) }, unsafe.source), /ID精度无效/)
|
||||
})
|
||||
|
||||
test('public native delivery DTO retains grouped fees and requires version and loading hashes', () => {
|
||||
const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java', import.meta.url), 'utf8')
|
||||
assert.ok(dto.includes('record ArrangeDelivery(Long recordVersion')); assert.ok(dto.includes('String loadingCompletionHash')); assert.ok(dto.includes('List<ArrangeGroup> groups'))
|
||||
test('requires original current-run loading completion receipt and registered disjoint fixtures',async()=>{
|
||||
const f=fixture();delete f.trips[0].loadingCommand;await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[0]))
|
||||
const missing=fixture();missing.cases.pop();await assert.rejects(verifyFleetFeeCoverage(missing.context,'automatic',missing.cases[0]));assert.equal(missing.calls.length,0)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
import { DISPATCH_BASE as BASE, id as stringId, dispatchIo, readDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
import { pickAll } from './phase5-overseas-fulfillment.mjs'
|
||||
import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
export const stableRows = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||||
const clone = value => structuredClone(value)
|
||||
const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) }
|
||||
const positive = value => /^[1-9]\d*$/.test(id(value))
|
||||
const sha = value => /^[a-f0-9]{64}$/i.test(value || '')
|
||||
const cents = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('费用必须为明确的非负两位金额'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) }
|
||||
const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) })
|
||||
const feeIdentity = fee => ({ financeFeeId: id(fee.financeFeeId), customerId: id(fee.customerId), currency: fee.currency, amount: String(fee.amount), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, status: fee.status, sourceKeys: [...fee.sourceKeys].sort() })
|
||||
|
||||
export function fleetConsumerIo(context, module, proof, namespace) {
|
||||
const io = dispatchIo(context, module, proof, namespace)
|
||||
@@ -27,188 +21,65 @@ export async function readyFleetDispatch(context, module, io, trip, { pickerToke
|
||||
io.check('真实履约写入须有正式持久化检查点', null, typeof context.checkpoint === 'function')
|
||||
let current = await readDispatch(io, trip.dispatchNo)
|
||||
io.check('本轮实车仓库及未发车范围', null, context.createdResources.fleetTrips?.includes(trip) && trip.tripNo
|
||||
&& current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && Number(current.data.counts.shippedPackageCount) === 0)
|
||||
&& current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && ['LOADING', 'DELIVERING'].includes(current.data.stage))
|
||||
if (current.data.stage === 'LOADING') {
|
||||
io.check('真实扫码需本轮PDA身份', null, typeof pickerToken === 'string' && pickerToken.length > 0)
|
||||
io.check('PDA逐来源真实扫码完成', null, await pickAll(context, pickerToken, trip.dispatchNo))
|
||||
await confirmFleetLoading(context, module, trip, pickerToken, actualMeasurements)
|
||||
current = await readDispatch(io, trip.dispatchNo)
|
||||
}
|
||||
io.check('安排前已真实扫码并显式装车完成', null, current.data.stage === 'AFTER_LOADING' && current.data.pickingReconciled === true
|
||||
io.check('装车完成后自动派送并保留真实拣货', null, current.data.stage === 'DELIVERING' && current.data.pickingReconciled === true
|
||||
&& Number(current.data.counts.packageCount) > 0 && Number(current.data.counts.pickedPackageCount) === Number(current.data.counts.packageCount)
|
||||
&& current.items.items.every(i => Number(i.pickedPackages) === Number(i.scheduledPackages)))
|
||||
return current
|
||||
}
|
||||
|
||||
// FeeCandidate未暴露sourceInboundId,仅在sourceKeys可唯一映射到实际原入仓时选择,混托歧义直接阻断。
|
||||
export function coveragePlan(preview, source, externalReceipt) {
|
||||
if (!preview.groups?.length) throw Error('缺少当前全车派送分组')
|
||||
const firstleg = preview.groups.flatMap(g => g.cargo.filter(c => c.sourceRegion === 'FIRSTLEG'))
|
||||
const inboundIds = [...new Set(firstleg.map(c => id(c.sourceInboundId)))].sort()
|
||||
if (!inboundIds.length || inboundIds.some(v => !positive(v)) || source.mode === 'PARTIAL' && inboundIds.length < 2) throw Error('FULL/PARTIAL需要本轮真实原入仓,PARTIAL至少两票')
|
||||
const selectedInbounds = new Set(source.mode === 'FULL' ? inboundIds : [inboundIds[0]])
|
||||
const originalFees = [], selections = new Map(), uncovered = []
|
||||
for (const group of preview.groups) {
|
||||
if (!group.groupKey || !positive(group.customerId) || !group.destination?.key || !sha(group.groupCargoHash) || !sha(group.feeCoverageCargoHash)
|
||||
|| !sha(group.externalFreightHash) || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.feeSources) || !Array.isArray(group.externalQuotes)
|
||||
|| !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('客户目的地或费用预览范围不完整')
|
||||
const own = group.cargo.filter(c => c.sourceRegion === 'FIRSTLEG')
|
||||
if (own.some(c => id(c.customerId) !== id(source.customerId))) throw Error('原费用跨客户,不能沿其他客户确认覆盖')
|
||||
if (group.externalQuotes.some(q => !externalReceipt || q.bookingNo !== externalReceipt.bookingNo || q.inboundCode !== externalReceipt.incomingNo
|
||||
|| id(q.customerId) !== id(group.customerId) || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash
|
||||
|| q.currency !== externalReceipt.finalBooking.cargo.currency || cents(q.amount) !== cents(externalReceipt.finalBooking.agreedAmount))) throw Error('当前外接报价与本轮运营确认金额或来源摘要不一致')
|
||||
for (const cargo of group.cargo.filter(c => c.sourceRegion !== 'FIRSTLEG')) {
|
||||
if (!externalReceipt || cargo.inboundCode !== externalReceipt.incomingNo || !externalReceipt.createRequest
|
||||
|| id(cargo.customerId) !== id(externalReceipt.createRequest.customerId)
|
||||
|| !group.externalQuotes.some(q => q.bookingNo === externalReceipt.bookingNo && q.inboundCode === cargo.inboundCode && id(q.customerId) === id(group.customerId))) throw Error('非头程货物没有本轮已确认外接报价')
|
||||
}
|
||||
const byInbound = new Map()
|
||||
for (const fee of group.feeSources) {
|
||||
if (id(fee.customerId) !== id(group.customerId) || !positive(fee.financeFeeId) || fee.currency !== 'CNY' || !['AVAILABLE', 'SETTLED'].includes(fee.status)
|
||||
|| !Number.isSafeInteger(fee.sourceVersion) || fee.sourceVersion < 1 || !fee.sourceHash || !Array.isArray(fee.sourceKeys) || !fee.sourceKeys.length
|
||||
|| !fee.services?.includes('DELIVERY_FEE')) throw Error('原费用客户、版本或来源不可用')
|
||||
cents(fee.amount)
|
||||
const actual = own.filter(c => fee.sourceKeys.includes(c.sourceKey)), ids = new Set(actual.map(c => id(c.sourceInboundId)))
|
||||
if (ids.size !== 1 || fee.sourceKeys.some(k => !actual.some(c => c.sourceKey === k))) throw Error('原费用sourceKeys不能唯一映射原入仓;需生产预览提供原费用sourceInboundId或无混托真实来源')
|
||||
const inbound = [...ids][0]
|
||||
if (!byInbound.has(inbound)) byInbound.set(inbound, fee)
|
||||
originalFees.push({ groupKey: group.groupKey, ...feeIdentity(fee) })
|
||||
}
|
||||
if (own.some(c => !byInbound.has(id(c.sourceInboundId)))) throw Error('本轮每票必须有实际原费用,不能借其他票补齐')
|
||||
selections.set(group.groupKey, [...byInbound].filter(([inbound]) => selectedInbounds.has(inbound)).map(([, fee]) => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] })))
|
||||
if (own.some(c => !selectedInbounds.has(id(c.sourceInboundId)))) uncovered.push(group.groupKey)
|
||||
/** 装车完成已生成单据并发车;仅读权威单据,不补人工费用或覆盖选择。 */
|
||||
export async function readAutomaticDelivery(io, current, trip) {
|
||||
const documents = current.data.documents
|
||||
const deliveries = documents?.deliveries, outbounds = documents?.outbounds
|
||||
io.check('自动派送单据完整且客户箱数守恒', null, current.data.stage === 'DELIVERING'
|
||||
&& Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0
|
||||
&& new Set(deliveries.map(d => d.deliveryNo)).size === deliveries.length
|
||||
&& new Set(outbounds.map(o => o.outboundNo)).size === outbounds.length
|
||||
&& deliveries.every(d => d.deliveryNo && d.status === 'DELIVERING' && outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))
|
||||
&& outbounds.every(o => o.outboundNo && o.status === 'OUTBOUNDED')
|
||||
&& deliveries.reduce((sum, d) => sum + Number(d.packageCount), 0) === Number(current.data.counts.packageCount)
|
||||
&& current.items.items.every(i => deliveries.some(d => id(d.customerId) === id(i.customerId) && d.destination?.key === i.destination?.key)))
|
||||
const snapshots = {}, feeRows = {}
|
||||
for (const ref of deliveries) {
|
||||
const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查自动生成派送的客户、目的地和费用事实')
|
||||
io.check('自动生成单据归属和货量正确', null, detail?.order?.dispatchNo === trip.dispatchNo && detail.order.deliveryNo === ref.deliveryNo
|
||||
&& id(detail.order.customerId) === id(ref.customerId) && detail.order.outboundNo === ref.outboundNo
|
||||
&& Number(detail.order.packageCount) === Number(ref.packageCount) && detail.order.status === 'DELIVERING'
|
||||
&& Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length)
|
||||
snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees)
|
||||
}
|
||||
const template = source.transferBody
|
||||
if (template?.loadingType !== 'PALLET' || template.appointmentAccount !== 'SELF' || template.fees?.length !== 1
|
||||
|| template.fees[0].direction !== 'RECEIVABLE' || template.fees[0].feeType !== 'DELIVERY_FEE' || cents(template.fees[0].amount) <= 0n) throw Error('覆盖用例须有明确的未覆盖派送应收金额')
|
||||
if (uncovered.length > 1 && uncovered.some(key => !Array.isArray(template.groupFees?.[key]) || template.groupFees[key].length !== 1)) throw Error('多个未覆盖目的地须由本轮用例明确逐组费用,不能自动分摊')
|
||||
const groups = preview.groups.map(group => {
|
||||
const fees = uncovered.includes(group.groupKey) ? clone(template.groupFees?.[group.groupKey] || template.fees) : []
|
||||
if (uncovered.includes(group.groupKey) && fees.length !== 1 || fees.some(f => f.direction !== 'RECEIVABLE' || f.feeType !== 'DELIVERY_FEE' || cents(f.amount) <= 0n || !['CNY', 'USD'].includes(f.currency) || f.currency !== template.fees[0].currency)) throw Error('逐组未覆盖应收不完整')
|
||||
return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: template.plannedDeliveryAt || new Date(Date.now() + 21600000).toISOString(),
|
||||
deliveryMethod: template.deliveryMethod || 'TRUCK_PALLET', waybillNo: template.waybillNo || null, carrierName: template.carrierName || null,
|
||||
pickupMethod: template.pickupMethod || 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: template.vehicleType || 'TRUCK', vehicleSize: template.vehicleSize || null,
|
||||
appointmentAccount: 'SELF', sellerName: null, remark: template.remark || null, externalFreightHash: group.externalFreightHash,
|
||||
feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: selections.get(group.groupKey), fees }
|
||||
})
|
||||
return { groups, originalFees, selectedInbounds: [...selectedInbounds], inboundIds }
|
||||
const command = trip.loadingCommand
|
||||
io.check('本轮装车成功回执完整', null, command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE'
|
||||
&& command.result.dispatchNo === trip.dispatchNo && command.result.stage === 'DELIVERING')
|
||||
return { outbounds, deliveries, snapshots, feeRows, confirmationRequest: command.body, command: command.result }
|
||||
}
|
||||
|
||||
async function noDocuments(io, source, label) {
|
||||
let total, rows = []
|
||||
for (let page = 1; ; page++) {
|
||||
const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId: id(source.customerId), size: '200', page: String(page) })}`, label)
|
||||
io.check('客户出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.page) === page && Number(data.size) === 200
|
||||
&& Number(data.total) >= 0 && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total)))
|
||||
total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break
|
||||
}
|
||||
io.check('拒绝后无本排车出库写入', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== source.dispatchNo))
|
||||
}
|
||||
|
||||
/** 两个本轮不重叠来源用例必须先准备;从不取消已扫码FULL再复用同批实物。 */
|
||||
export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false, pickerToken, actualMeasurements, externalTrip } = {}) {
|
||||
const cases = context.createdResources?.fleetFeeCoverageCases
|
||||
if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) throw Error('原费用验收需要本轮专用、未执行的排车用例;保留原请求')
|
||||
if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted)
|
||||
throw Error('验收需要本轮专用、未执行排车用例;保留原请求')
|
||||
const pair = ['FULL', 'PARTIAL'].map(mode => cases.filter(c => c.marker === context.marker && c.mode === mode))
|
||||
if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮生产者预先准备FULL/PARTIAL两组不重叠真实排车,不能取消已扫码FULL复用同批实物')
|
||||
if (reconcilePersisted && (!source.proof?.confirmationRequest || !source.proof?.outboundNo || !source.proof?.deliveryNos?.length)) throw Error('续验须有原成功命令和已持久化单据,不可重造')
|
||||
if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮两组不重叠真实排车')
|
||||
const proof = reconcilePersisted ? source.proof : source.proof = { marker: context.marker, status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] }
|
||||
const io = fleetConsumerIo(context, module, proof, `FEE-${source.mode}`), path = `${BASE}/${encodeURIComponent(source.dispatchNo)}`
|
||||
const io = fleetConsumerIo(context, module, proof, `AUTO-${source.mode}`)
|
||||
try {
|
||||
const scopes = []
|
||||
for (const [candidate] of pair) scopes.push(await readDispatch(io, candidate.dispatchNo))
|
||||
io.check('两组真实来源和实托均不重叠', null, scopes.every(s => s.items.items.length > 0) && scopes[0].items.items.every(i => !scopes[1].items.items.some(j => id(i.warehousePalletId) === id(j.warehousePalletId) || i.sourceKey === j.sourceKey)))
|
||||
const trip = context.createdResources.fleetTrips?.find(t => t.dispatchNo === source.dispatchNo)
|
||||
io.check('本排车有本轮正式车辆审核生产回执', null, Boolean(trip))
|
||||
const loaded = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements })
|
||||
const preview = await io.get(`${path}/delivery-arrangement`, '读取原生全车费用预览')
|
||||
io.check('原生派送门禁、版本及实载证据完整', null, preview?.dispatchNo === source.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING'
|
||||
&& id(version(preview.recordVersion)) === id(version(loaded.data.recordVersion)) && sha(preview.cargoHash) && sha(preview.loadingCompletionHash)
|
||||
&& (reconcilePersisted ? Boolean(preview.arrangement) : preview.arrange?.allowed === true && !preview.arrangement))
|
||||
const receipt = externalTrip?.externalReceipt
|
||||
if (externalTrip && (!context.createdResources.fleetTrips.includes(externalTrip) || externalTrip.dispatchNo !== source.dispatchNo || receipt?.status !== 'VERIFIED')) throw Error('外接费用只接受本轮同主单真实运营确认')
|
||||
const plan = coveragePlan(preview, source, receipt)
|
||||
const totals = new Map()
|
||||
for (const c of preview.groups.flatMap(g => g.cargo)) totals.set(id(c.dispatchItemId), (totals.get(id(c.dispatchItemId)) || 0) + Number(c.packages))
|
||||
io.check('费用分组逐实物客户与箱数核平', null, totals.size === loaded.items.items.length && loaded.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages)
|
||||
&& preview.groups.filter(g => g.cargo.some(c => id(c.dispatchItemId) === id(i.dispatchItemId))).every(g => id(g.customerId) === id(i.customerId) && g.destination.key === i.destination.key)))
|
||||
const body = reconcilePersisted ? proof.confirmationRequest : { recordVersion: version(preview.recordVersion), idempotencyKey: commandKey(context.marker, `FEE-${source.mode}-${source.dispatchNo}`, 'arrange'),
|
||||
cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups: plan.groups }
|
||||
if (!reconcilePersisted) { proof.confirmationRequest = clone(body); proof.originalFees = clone(plan.originalFees); await context.checkpoint?.() }
|
||||
else io.check('原成功请求仍为同货物和实载', null, body.cargoHash === preview.cargoHash && body.loadingCompletionHash === preview.loadingCompletionHash)
|
||||
const reject = async (label, changed, pattern) => {
|
||||
const before = await readDispatch(io, source.dispatchNo)
|
||||
await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: commandKey(context.marker, `${source.dispatchNo}-${source.mode}`, label) }, label, pattern)
|
||||
for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, `${label}后无单据`)
|
||||
io.check(`${label}后整车及实物零漂移`, null, JSON.stringify(await readDispatch(io, source.dispatchNo)) === JSON.stringify(before))
|
||||
}
|
||||
if (!reconcilePersisted) {
|
||||
for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, '执行前确认无出库单')
|
||||
await reject('陈旧全车货物摘要拒绝', { cargoHash: `${body.cargoHash[0] === '0' ? '1' : '0'}${body.cargoHash.slice(1)}` }, /货物.*变化/)
|
||||
const stale = clone(body.groups), selected = stale.find(g => g.feeCoverage.length).feeCoverage[0]; selected.sourceVersion = selected.sourceVersion === 2147483647 ? selected.sourceVersion - 1 : selected.sourceVersion + 1
|
||||
await reject('陈旧原费用版本拒绝', { groups: stale }, /原费用版本.*变化/)
|
||||
const wrong = clone(body.groups)
|
||||
if (source.mode === 'FULL') wrong[0].fees = clone(source.transferBody.fees)
|
||||
else wrong.find(g => g.fees.length).fees = []
|
||||
await reject(source.mode === 'FULL' ? '全覆盖重复应收拒绝' : '部分覆盖漏填应收拒绝', { groups: wrong }, source.mode === 'FULL' ? /覆盖.*重复|覆盖.*登记/ : /未覆盖.*费用/)
|
||||
if (externalTrip) {
|
||||
const extra = clone(body.groups), quoted = preview.groups.find(g => g.externalQuotes.length), input = extra.find(g => g.groupKey === quoted.groupKey), quote = quoted.externalQuotes[0]
|
||||
input.fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接派送应收反例', currency: quote.currency, amount: quote.amount })
|
||||
await reject('外接已有约定运费禁止追加重复应收', { groups: extra }, /重复|覆盖.*登记/)
|
||||
}
|
||||
} else {
|
||||
const stored = await io.get(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, '先核对原成功出库和派送集合')
|
||||
io.check('原单据完整且未执行', null, stored?.outbound?.dispatchNo === source.dispatchNo && stored.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& stableRows((stored.deliveries || []).map(d => d.deliveryNo)) === stableRows(proof.deliveryNos) && stored.deliveries.every(d => d.status === 'WAIT_DELIVERY'))
|
||||
}
|
||||
const result = await io.request('POST', `${path}/transfer-delivery`, body, '按全车真实分组确认原费用并安排派送'), arranged = result?.result
|
||||
io.check('原生命令返回完整客户出库和客户目的地派送', null, result?.dispatchNo === source.dispatchNo && result.action === 'ARRANGE_DELIVERY' && result.status === 'ACTIVE' && result.stage === 'AFTER_LOADING'
|
||||
&& BigInt(version(result.recordVersion)) > BigInt(version(body.recordVersion))
|
||||
&& arranged?.dispatchNo === source.dispatchNo && arranged.cargoHash === body.cargoHash && arranged.deliveries?.length === preview.groups.length
|
||||
&& new Set(arranged.deliveries.map(d => d.deliveryNo)).size === arranged.deliveries.length
|
||||
&& arranged.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size
|
||||
&& arranged.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && arranged.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0) === Number(o.packageCount)) && arranged.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY'
|
||||
&& preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key
|
||||
&& g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount))
|
||||
&& arranged.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))))
|
||||
if (reconcilePersisted) io.check('仅恢复原出库和派送集合', null, result.replayed === true && stableRows(arranged.deliveries.map(d => d.deliveryNo)) === stableRows(proof.deliveryNos))
|
||||
proof.outbounds = arranged.outbounds; proof.deliveries = arranged.deliveries; proof.command = result
|
||||
proof.outboundNo = arranged.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo
|
||||
proof.deliveryNos = arranged.deliveries.map(d => d.deliveryNo).sort()
|
||||
const snapshots = {}, feeRows = {}
|
||||
let added = 0n
|
||||
for (const ref of arranged.deliveries) {
|
||||
const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = body.groups.find(g => g.groupKey === ref.groupKey)
|
||||
const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查当前客户目的地费用和原费快照')
|
||||
io.check('派送只归当前客户和目的地', null, detail?.order?.dispatchNo === source.dispatchNo && detail.order.deliveryNo === ref.deliveryNo
|
||||
&& id(detail.order.customerId) === id(group.customerId) && detail.order.outboundNo === ref.outboundNo
|
||||
&& id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId))
|
||||
const evidence = detail.feeCoverage, expectedCargo = group.cargo.map(cargoIdentity)
|
||||
if (input.feeCoverage.length) {
|
||||
io.check('原费快照保留真实货物、确认人、时间和每票引用', null, evidence?.cargoHash === group.feeCoverageCargoHash && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt))
|
||||
&& stableRows(evidence.cargo.map(cargoIdentity)) === stableRows(expectedCargo) && Array.isArray(evidence.references)
|
||||
&& stableRows(evidence.references.map(r => ({ financeFeeId: id(r.source?.financeFeeId), sourceVersion: r.source?.sourceVersion, sourceHash: r.source?.sourceHash, services: r.services }))) === stableRows(input.feeCoverage)
|
||||
&& evidence.references.every(r => id(r.source.customerId) === id(group.customerId) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(r.source.sourceType)
|
||||
&& group.cargo.some(c => id(c.sourceInboundId) === id(r.source.sourceInboundId)) && group.feeSources.some(f => id(f.financeFeeId) === id(r.source.financeFeeId)
|
||||
&& f.currency === r.source.currency && cents(f.amount) === cents(r.source.amount) && f.status === r.source.status)))
|
||||
} else io.check('未选原费不伪造覆盖证据', null, evidence == null || evidence.references?.length === 0)
|
||||
io.check('费用为本客户明确正数应收列表', null, Array.isArray(detail.fees) && detail.fees.every(f => f.feeDirection && f.feeType))
|
||||
const receivables = detail.fees.filter(f => f.feeDirection === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE' && f.sourceStatus !== 'VOIDED')
|
||||
const expectedAmounts = [...input.fees.map(f => `${f.currency}:${cents(f.amount)}`), ...group.externalQuotes.filter(q => cents(q.amount) > 0n).map(q => `${q.currency}:${cents(q.amount)}`)].sort()
|
||||
io.check('仅未覆盖货物和原外接报价产生应收,禁止重复或零值', null, stableRows(receivables.map(f => `${f.currency}:${cents(f.amount)}`).sort()) === stableRows(expectedAmounts)
|
||||
&& receivables.every(f => cents(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED'))
|
||||
added += input.fees.reduce((n, f) => n + cents(f.amount), 0n)
|
||||
snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees)
|
||||
}
|
||||
const replayed = await replay(io, `${path}/transfer-delivery`, body, result, '费用确认原键原载荷重放')
|
||||
io.check('费用安排重放明确恢复原成功命令', null, replayed.replayed === true)
|
||||
for (const ref of arranged.deliveries) {
|
||||
const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '原键重放后回查每客户费用')
|
||||
io.check('重放不改确认快照也不新增应收', null, JSON.stringify(detail.feeCoverage) === JSON.stringify(snapshots[ref.deliveryNo]) && stableRows(detail.fees) === feeRows[ref.deliveryNo])
|
||||
}
|
||||
const after = await io.get(`${path}/delivery-arrangement`, '回查原费用来源未被确认改变')
|
||||
const currentFees = after.groups.flatMap(g => g.feeSources.map(f => ({ groupKey: g.groupKey, ...feeIdentity(f) })))
|
||||
io.check('原费用版本金额状态保持不变', null, stableRows(currentFees) === stableRows(proof.originalFees))
|
||||
proof.snapshots = snapshots; proof.newReceivable = Number(added) / 100; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof
|
||||
io.check('本排车有本轮生产回执', null, Boolean(trip) && (!externalTrip || externalTrip === trip))
|
||||
const current = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements })
|
||||
const execution = await readAutomaticDelivery(io, current, trip)
|
||||
if (reconcilePersisted) io.check('恢复仅读原生成单据集合', null, stableRows(proof.deliveryNos) === stableRows(execution.deliveries.map(d => d.deliveryNo)))
|
||||
Object.assign(proof, execution, { outboundNo: execution.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo,
|
||||
deliveryNos: execution.deliveries.map(d => d.deliveryNo).sort(), feeCoverageSelection: 'NOT_APPLICABLE_AUTOMATIC_DELIVERY', status: 'VERIFIED' })
|
||||
await context.checkpoint?.(); return proof
|
||||
} catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function fixture(fault = '') {
|
||||
const nativeItem = () => ({ dispatchItemId: '501', customerId: CUSTOMER, sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), sourceType: 'RETURN_RECEIPT', planPalletId: null, returnReceiptId: '300', warehousePalletId: '100', palletNo: context.palletCode,
|
||||
inboundCode: 'IN-A', destination, scheduledPackages: 2, pickedPackages: picked ? 2 : 0, positions: [{ positionId: '201', availablePackages: picked ? 0 : 2 }] })
|
||||
const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage,
|
||||
documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] },
|
||||
reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked,
|
||||
vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] })
|
||||
const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 })
|
||||
@@ -41,7 +42,8 @@ function fixture(fault = '') {
|
||||
const stockLedger = () => returned ? [{ id: '901', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletNo: context.palletCode, businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-NEW', outboundOrderNo: 'OUT-ORIGINAL', deltaPackages: 2 }] : []
|
||||
const envelope = (data, code = '200', message = '') => ({ http: 200, body: { code, message, data: copy(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' })
|
||||
const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: CUSTOMER, warehouseId: WAREHOUSE, tripNo: trip.tripNo,
|
||||
departure: { dispatchNo: trip.dispatchNo, action: 'DEPART', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: '2026-10-19T01:00:00Z', deliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }] } } } },
|
||||
departureDeliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }],
|
||||
departure: { dispatchNo: trip.dispatchNo, action: 'LOADING_COMPLETE', status: 'ACTIVE', stage: 'DELIVERING', result: { confirmedLoad: { manifestHash: 'ORIGINAL' } } } } },
|
||||
assert(_m, label, _r, passed) { if (!passed) throw Error(label) }, addStep(_m, label, _r, passed) { if (!passed) throw Error(label) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data },
|
||||
async checkpoint() { checkpoints.push(copy(trip.returnRedispatch)) },
|
||||
async requestMultipart(method, path, file) {
|
||||
@@ -52,6 +54,7 @@ function fixture(fault = '') {
|
||||
async request(method, raw, body, token) {
|
||||
requests.push({ method, path: raw, body: copy(body), token })
|
||||
const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, DISPATCH), ledgerKey = `${method}:${normalized}:${body?.idempotencyKey}`
|
||||
if (normalized.endsWith('/loading-complete') && !token && ledger.has(ledgerKey)) return envelope(null, 'A0403', '该请求标识不属于当前操作员')
|
||||
if (body?.idempotencyKey && ledger.has(ledgerKey) && !path.endsWith('/resources/release')) { const prior = ledger.get(ledgerKey); assert.deepEqual(body, prior.body); return envelope({ ...copy(prior.data), replayed: true }) }
|
||||
const remember = data => { if (body?.idempotencyKey) ledger.set(ledgerKey, { body: copy(body), data: copy(data) }); return envelope(data) }
|
||||
if (path === '/api/platform/inbounds') { const name = params.get('inboundCode').slice(-1), delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned && fault !== 'domestic-blocked-B'; return envelope({ records: [{ inboundCode: `IN-${name}`, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) }
|
||||
@@ -83,7 +86,7 @@ function fixture(fault = '') {
|
||||
sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), customerId: CUSTOMER, warehouseId: WAREHOUSE, orderNo: 'LM-A', inboundCode: 'IN-A', palletNo: context.palletCode, destination, availablePackages: fault === 'wrong-candidate' ? 4 : 2, appointmentBatches: [], blockReasons: [] }] }) }
|
||||
if (path === DISPATCH && method === 'POST') { if (!body.cargo.length) { nextCreated = true; vehicleOwner = 'TR-NEXT'; driverOwner = 'TR-NEXT'; return remember(command('CREATE', {}, true)) }; assert.equal(body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(body.cargo[0].planPalletId, null); created = true; return remember(command('CREATE')) }
|
||||
if (path === DISPATCH) return envelope({ records: [{ ...detail(params.get('dispatchNo') === 'DP-NEXT'), warehouseId: WAREHOUSE }] })
|
||||
if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber; return envelope({ id: '601' }) }
|
||||
if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber.toUpperCase(); return envelope({ id: '601' }) }
|
||||
if (path.endsWith('/fleet/drivers') && method === 'POST') return envelope({ id: '701', recordVersion: 0 })
|
||||
if (path.endsWith('/fleet/drivers/701/review')) return envelope({ reviewStatus: 'APPROVED' })
|
||||
if (path === `${BASE}/fleet/trips`) { const trips = nextCreated ? [tripDetail(false).trip, tripDetail(true).trip] : [tripDetail(false).trip]; return envelope({ records: trips, page: 1, size: 200, total: trips.length }) }
|
||||
@@ -121,9 +124,7 @@ function fixture(fault = '') {
|
||||
assert.equal(body.recordVersion, recordVersion, raw)
|
||||
if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) }
|
||||
if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) }
|
||||
if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'AFTER_LOADING'; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
if (normalized.endsWith('/transfer-delivery')) { assert.equal(stage, 'AFTER_LOADING'); arranged = true; recordVersion++; return remember(command('ARRANGE_DELIVERY', { dispatchNo: 'DP-NEW', cargoHash: body.cargoHash, outbounds: [outbound()], deliveries: [ref()] })) }
|
||||
if (normalized.endsWith('/depart')) { assert.equal(arranged, true); departed = true; stage = 'DELIVERING'; recordVersion++; return remember(command('DEPART', { departedAt: '2026-10-19T03:00:00Z', shippedPackages: 2, outbounds: [outbound()], deliveries: [ref()] })) }
|
||||
if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) }
|
||||
throw Error(`Unexpected ${method} ${raw}`)
|
||||
} }
|
||||
const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } }
|
||||
@@ -136,10 +137,11 @@ test('native one-main multi-source return redispatch executes real APIs through
|
||||
const f = fixture(), result = await run(f)
|
||||
assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3)
|
||||
assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY'); assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED')
|
||||
assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'DEPART'); assert.equal(result.afterRedispatch.departed.result.shippedPackages, 2)
|
||||
assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'LOADING_COMPLETE'); assert.equal(result.afterRedispatch.departed.counts.shippedPackageCount, 2)
|
||||
assert.equal(result.inventory.map(s => s.ledger.length).every((n, i) => n === (i === 0 ? 0 : 1)), true)
|
||||
assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')); assert.ok(f.checkpoints.some(p => p.requests.at(-1)?.status === 'SENDING' && p.requests.at(-1)?.file?.sha256))
|
||||
assert.ok(f.requests.findIndex(r => r.path.endsWith('/loading-complete')) < f.requests.findIndex(r => r.path.endsWith('/transfer-delivery')))
|
||||
assert.ok(f.requests.some(r => r.path.endsWith('/loading-complete')))
|
||||
assert.equal(f.requests.some(r => /\/(transfer-delivery|depart)$/.test(r.path)), false)
|
||||
assert.equal(f.requests.some(r => r.path.includes('/pre-arrange')), false)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {join,resolve} from 'node:path'
|
||||
import {redactEvidence} from '../fba-core-regression-test-env.mjs'
|
||||
import { randomUUID, createHash } from 'node:crypto'
|
||||
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './phase5-overseas-fulfillment.mjs'
|
||||
import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
|
||||
import { version, createBody, candidatePage, readDispatch, replay as replayCommand } from './grouped-dispatch-acceptance.mjs'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg'
|
||||
@@ -34,17 +34,17 @@ export function selectReturnSources(rows) {
|
||||
return { a, b }
|
||||
}
|
||||
|
||||
/** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */
|
||||
/** 只消费本轮自动发运事实;来源恢复保留已启动派送,每次请求发送前持久记录。 */
|
||||
export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) {
|
||||
const run = context.createdResources?.fleetAcceptance
|
||||
if (Object.keys(dependencies).some(name => !['wait', 'onBeforeRedispatchSignature'].includes(name))) throw Error('Only readonly audit and wait observers may be supplied; native production, picking and signature cannot be replaced')
|
||||
const departure = run?.departure
|
||||
if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker
|
||||
|| run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART'
|
||||
|| !departure.result?.departedAt || !input?.pickerToken
|
||||
|| run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'LOADING_COMPLETE'
|
||||
|| !departure.result?.confirmedLoad || !input?.pickerToken
|
||||
|| id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId)
|
||||
|| JSON.stringify([departure.dispatchNo]) !== JSON.stringify([...(input.dispatchNos || [])].sort())
|
||||
|| JSON.stringify((departure.result.deliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort())
|
||||
|| JSON.stringify((run.departureDeliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort())
|
||||
|| !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function')
|
||||
throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart')
|
||||
const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2,
|
||||
@@ -271,20 +271,19 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
const picked = await inventory('RETURN_PICKED')
|
||||
check('Actual returned stock is picked once without rewriting the stock-in ledger', picked.available === 0 && picked.picked === 2 && JSON.stringify(picked.ledger) === JSON.stringify(stockIn.ledger))
|
||||
await confirmFleetLoading(io, module, newTrip, input.pickerToken, input.actualMeasurements)
|
||||
const transfer = await arrangeDispatchDelivery(io, arranged.dispatchNo)
|
||||
check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)
|
||||
&& id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2)
|
||||
// 装车完成已原子生成派送并发运,只回查单据,不重复发送退役安排和发车命令。
|
||||
const current = await request('GET', `${BASE}/dispatches/${encodeURIComponent(arranged.dispatchNo)}`)
|
||||
check('New whole is loaded and fully arranged before actual departure', current.status === 'ACTIVE' && current.stage === 'AFTER_LOADING' && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 0)
|
||||
const departBody = { recordVersion: version(current.recordVersion), idempotencyKey: key(), remark: '退回2箱重新实际发车' }
|
||||
const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart`
|
||||
const departed = await request('POST', departPath, departBody, input.pickerToken)
|
||||
check('New DEPART ships exactly two returned boxes', departed.dispatchNo === arranged.dispatchNo && departed.action === 'DEPART' && departed.status === 'ACTIVE'
|
||||
&& departed.stage === 'DELIVERING' && departed.result?.departedAt && Number(departed.result.shippedPackages) === 2
|
||||
&& departed.result.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === transfer.deliveries[0].deliveryNo)
|
||||
const departedAgain = await request('POST', departPath, departBody, input.pickerToken)
|
||||
const facts = value => { const result = copy(value); delete result.replayed; return JSON.stringify(result) }
|
||||
check('Departure replay does not duplicate shipping', departedAgain.replayed === true && facts(departedAgain) === facts(departed))
|
||||
const transfer = current.documents
|
||||
check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)
|
||||
&& id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2
|
||||
&& transfer.deliveries[0].status === 'DELIVERING' && transfer.outbounds?.length === 1
|
||||
&& transfer.outbounds[0].status === 'OUTBOUNDED' && transfer.outbounds[0].outboundNo === transfer.deliveries[0].outboundNo
|
||||
&& id(transfer.outbounds[0].customerId) === customerId)
|
||||
const departed = newTrip.loadingCommand.result
|
||||
check('Loading completion ships exactly two returned boxes', current.status === 'ACTIVE' && current.stage === 'DELIVERING'
|
||||
&& Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 2
|
||||
&& departed.action === 'LOADING_COMPLETE' && departed.dispatchNo === arranged.dispatchNo
|
||||
&& version(current.recordVersion) === version(departed.recordVersion))
|
||||
const shipped = await inventory('RETURN_DEPARTED'); check('Returned stock ships once and retains the original immutable stock-in ledger', shipped.available === 0 && shipped.picked === 0 && JSON.stringify(shipped.ledger) === JSON.stringify(stockIn.ledger))
|
||||
const newDelivery = await delivery(transfer.deliveries[0].deliveryNo)
|
||||
proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo,
|
||||
|
||||
@@ -145,7 +145,7 @@ function server(fault = '') {
|
||||
assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST')
|
||||
assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }])
|
||||
loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM')
|
||||
detail.stage = 'AFTER_LOADING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })
|
||||
detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })
|
||||
}
|
||||
if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) {
|
||||
const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind')
|
||||
@@ -304,7 +304,7 @@ test('PDA original-actor replay restores loading while another platform actor is
|
||||
const beforeCount = s.detail.counts.pickedPackageCount
|
||||
const v = await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt())
|
||||
assert.equal(v, '9007199254741004'); assert.equal(proof.reassignment.status, 'REJECTED_WITHOUT_MUTATION')
|
||||
assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'AFTER_LOADING')
|
||||
assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'DELIVERING')
|
||||
const writes = s.calls.filter(c => c.path.endsWith('/loading-complete'))
|
||||
assert.equal(writes.length, 3)
|
||||
assert.deepEqual(writes.map(c => c.body), [writes[0].body, writes[0].body, writes[0].body])
|
||||
@@ -327,7 +327,7 @@ test('platform positive loading remains executable in its own scenario with orig
|
||||
assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${D}/loading-complete`, 'PLATFORM'], [`${D}/loading-complete`, 'PLATFORM']])
|
||||
assert.equal(proof.loadingCommand.replay.replayed, true)
|
||||
assert.equal(proof.loadingCommand.platformPositiveLoading, 'VERIFIED'); assert.equal(proof.loadingCommand.platformRejection, undefined)
|
||||
assert.equal(s.detail.stage, 'AFTER_LOADING')
|
||||
assert.equal(s.detail.stage, 'DELIVERING')
|
||||
})
|
||||
|
||||
for (const fault of ['cross-actor-accepted', 'cross-actor-generic-forbidden', 'cross-actor-mutates-main', 'cross-actor-mutates-items', 'cross-actor-mutates-loading', 'cross-actor-unknown']) test(`cross-actor loading cannot claim isolation: ${fault}`, async () => {
|
||||
|
||||
@@ -219,7 +219,7 @@ async function verifyPreDepartureReassignment(context, module, proof) {
|
||||
state.status = 'REJECTED_WITHOUT_MUTATION'
|
||||
}
|
||||
|
||||
export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements) {
|
||||
export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) {
|
||||
if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明')
|
||||
if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试')
|
||||
const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}`
|
||||
@@ -233,8 +233,9 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu
|
||||
proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements
|
||||
proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion,
|
||||
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
|
||||
if (beforeComplete) await beforeComplete(proof.loadingCommand)
|
||||
const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken)
|
||||
check(context, module, '显式装车才进入装车后', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'AFTER_LOADING'
|
||||
check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING'
|
||||
&& confirmed.result?.confirmedLoad?.manifestHash === preview.manifestHash && version(confirmed.recordVersion) > version(preview.recordVersion))
|
||||
const returned = confirmed.result.confirmedLoad.pallets
|
||||
check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length
|
||||
|
||||
@@ -244,7 +244,8 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i
|
||||
inboundHistoryCount: after.facts.inbound.statusHistory.length }
|
||||
}
|
||||
|
||||
async function reusablePallets(context, warehouseId) {
|
||||
/** 固定造数只允许指定托盘;显式通用模式才读取其他已有托盘。缺失不自动替换。 */
|
||||
export async function reusablePallets(context, warehouseId) {
|
||||
const cacheKey = asId(warehouseId)
|
||||
const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true
|
||||
const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}`
|
||||
@@ -272,10 +273,12 @@ async function reusablePallets(context, warehouseId) {
|
||||
reusablePalletsByWarehouse.set(scopedCacheKey, pallets)
|
||||
return pallets
|
||||
}
|
||||
// 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。
|
||||
context.assert(MODULE, '固定称重托盘主数据完整', null, false,
|
||||
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS)
|
||||
context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失',
|
||||
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`,
|
||||
'fixture', { blocking: false })
|
||||
`warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`,
|
||||
'fixture', { blocking: true })
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await context.request('GET',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs'
|
||||
|
||||
const cn = readInitialization()
|
||||
@@ -17,7 +18,8 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文
|
||||
})
|
||||
|
||||
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 228]]) {
|
||||
// 国内补齐订单/仓库流水表;海外含自有业务四表与当前运输、流水基线。
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 229], [overseas, overseasSchemas, domesticSchemas, 234]]) {
|
||||
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
|
||||
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
|
||||
const body = sql.replace(/^--.*$/gm, '')
|
||||
@@ -173,7 +175,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
|
||||
})
|
||||
|
||||
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => {
|
||||
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614,
|
||||
for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 440, iam_role_permission: 624,
|
||||
iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4,
|
||||
fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4,
|
||||
file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name)
|
||||
@@ -200,8 +202,15 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完
|
||||
for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id)
|
||||
const merchant = roleAuthorities(cn, 'MERCHANT_OWNER')
|
||||
for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement',
|
||||
'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code)
|
||||
'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code)
|
||||
assert.ok(!merchant.has('merchant:warehouse:delivery:create'), '派送由排车履约生成,客户只读不能独立创建')
|
||||
const admin = roleAuthorities(cn, 'PLATFORM_ADMIN')
|
||||
for (const domain of ['owned-business', 'outsourced-business']) {
|
||||
for (const action of ['list', 'edit', 'upload']) {
|
||||
assert.ok(admin.has(`platform:fleet:${domain}:${action}`))
|
||||
assert.ok(!merchant.has(`platform:fleet:${domain}:${action}`))
|
||||
}
|
||||
}
|
||||
assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator')
|
||||
for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) {
|
||||
assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`))
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { reusablePallets } from './inbound-measurement.mjs'
|
||||
|
||||
test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓可重新验证', async () => {
|
||||
const requests = [], gaps = [], checks = []
|
||||
let complete = false
|
||||
const context = {
|
||||
context: {},
|
||||
async request(method, path) {
|
||||
requests.push({ method, path })
|
||||
const query = new URL(path, 'http://offline').searchParams
|
||||
const code = query.get('palletCode')
|
||||
assert.ok(code, '固定模式禁止用无编号列表替换缺失资源')
|
||||
return { records: complete || code === 'TP-001'
|
||||
? [{ id: code === 'TP-001' ? '1' : '2', palletCode: code, warehouseId: 'offline-fixed-pallet' }] : [] }
|
||||
},
|
||||
apiSuccess(_module, _name, response) { return response },
|
||||
assert(_module, name, _response, passed) { checks.push({ name, passed }) },
|
||||
addCoverageGap(...args) { gaps.push(args) },
|
||||
}
|
||||
assert.equal(await reusablePallets(context, 'offline-fixed-pallet'), null)
|
||||
assert.equal(requests.length, 2)
|
||||
assert.ok(checks.some(row => !row.passed))
|
||||
assert.equal(gaps[0].at(-1).blocking, true)
|
||||
complete = true
|
||||
assert.deepEqual(await reusablePallets(context, 'offline-fixed-pallet'), [
|
||||
{ id: '1', code: 'TP-001' }, { id: '2', code: 'YW-TP-002' },
|
||||
])
|
||||
assert.equal(requests.length, 4, '失败不得缓存为成功')
|
||||
})
|
||||
|
||||
const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
|
||||
|
||||
Reference in New Issue
Block a user