优化
This commit is contained in:
@@ -32,9 +32,9 @@
|
||||
|
||||
`fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。
|
||||
|
||||
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
|
||||
`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` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `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 或页面验收证据。
|
||||
|
||||
@@ -54,11 +54,11 @@
|
||||
|
||||
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
|
||||
|
||||
`fleet-trip-fulfillment.mjs` 是内部车次验收助手,不是独立入口。五期与F40模块通过正式API建立本轮车辆容量、测试司机及PNG证照上传/审核、车次关联、目的站匹配及本单调度审核通过;拣货后确认明确标记为测试的实物测量,通过整车理货审核后携带最新车次版本统一发车。资源及文件ID保留到报告,不自动删除、不以SQL修改状态;失败停止,不能跳过资格或装载。`mixed-container-outbound-test-env.mjs` 与 `overseas-relabel-fulfillment-acceptance.mjs` 的留存场景须在拣货前已有正式车次关联和通过的调度审核,不能对已经开工的旧单补造关联;它们确认受控测试装载、理货审核并继续履约。改派检查保留拣货事实,但重新通过每张单的调度审核后再确认装载。`fleet-trip-fulfillment-contract.test.mjs` 离线验证重复实托、目的地及准备顺序,不代表HTTP验收。
|
||||
`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;五期与F40通过正式API维护本轮车辆、司机及证照,提交并通过调度/理货两级审核,再读取该排车唯一自动生成的Fleet资源记录。PDA拣货核平后,显式确认装车完成,再由平台安排全车客户目的地派送,最后使用当前主单版本发车;不再独立创建、绑定或审核Fleet车次。实载托位、重量、体积及卸货站须有本轮真实来源证据,不能使用计费量或固定值;ID和版本无损保留,Long箱数字符串仅在安全数量边界归一化。资源及文件ID留在报告,失败保留原命令。直接调用者也须同步消费原生DTO及该顺序;合同通过不代表HTTP或PDA硬件验收。
|
||||
|
||||
`prepareFleetTrip` 创建的本轮车次必须完成发车前改派验证:`confirmFleetLoading` 先确认装载,再创建等容量替换车辆和经文件上传/审核的替班司机,经正式分配接口改派。回查客户关联、原货物、拣货数量、路线和到达时间未改变,原装载确认已清除;旧版本确认须被拒绝且不产生写入,最后按新版本重新确认。返回最终版本供既有 PDA 就绪检查和整车发车使用。报告 `createdResources.fleetTrips[].reassignment` 留存前后快照、替换资源、原请求和结果,只有全部通过才标记 `VERIFIED`;失败保留 `CHECKING`,同一证明对象禁止自动重试或重造。顶层资源引用随后更新为替换资源供结案/释放验证,原资源仍保留在快照中。留存场景不执行这段新建资源与改派流程。
|
||||
`prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。
|
||||
|
||||
五期和F40签收后继续调用 `finishFleetTrip`:正式结案并同键重放,通过两个无货计划及一辆隔离用测试车辆验证“结案仍占用车辆、只结束车辆仍占用司机、两项结束后可重新分配、旧结束请求重放不能清除新占用”。仅409不足以通过,必须是相应资源占用冲突;全部完成后以正式取消命令取消无货计划并保留历史。测试车辆/司机在末站结束,不含返程任务;地点、时间、原请求及计划号保存于 `createdResources.fleetTrips[].closure`。失败保留CHECKING及原资源,不自动重造或修复旧fixture;离线模拟通过不等于这些真实HTTP场景已执行。
|
||||
五期和F40签收后继续调用`finishFleetTrip`:货物结案、车辆任务结束、司机任务结束分别执行并重放,按真实资源占用所有者核对“结案仍占用车辆、只结束车辆仍占用司机”。两项结束后,通过原生Create创建本轮空排车取得下一趟资源占用,再重放旧结束请求,证明不能清除新占用;不调用退役Fleet空计划创建接口。只有当前空排车无来源/无箱/无扫码且创建结果明确时,才能按最新版本取消探针;结果未知不清理。地点、时间、原请求及业务号留在`createdResources.fleetTrips[].closure`,不含返程任务,离线模拟不能替代真实环境验收。
|
||||
|
||||
`phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
`phase4-overseas-pda.mjs` 的收货入口对本轮 `containerBookingId + containerNo` 做有界候选收敛等待(最多 30 次、间隔 1 秒),只有唯一且可操作的原柜才继续;鉴权失败、畸形响应和重复候选立即阻断。它用于等待异步船节点投影,不构成事件重放证据。F40 在成功和失败时均保留 `receivingCandidateEvidence` 的逐次查询身份、结果和诊断信息,不覆盖已有收货会话,不借其他柜或历史柜继续。
|
||||
|
||||
多目的地排车 C52:`phase4-overseas-dispatch.mjs` 在本轮实际收货产生的同仓同客户托盘上验证多 FBA 目的地及重复目的地托盘的增加、移除、重放和拒绝后零漂移。路线期望从实际排车明细及本轮冻结候选独立推导,记录 `routeLifecycle`,最终恢复原主链排车并释放额外测试托盘;缺少至少两个目的地及同目的地双托前置时明确阻断,不借历史数据或用单目的地代替。复用 candidates、draft-locks/lock、pre-arrange、详情和 DELETE items 正式 API;定向入口仍为 `--modules phase4-overseas-dispatch`,会写 TEST,使用新报告路径,失败保留样本,不自动删除重跑。合同为 `phase4-overseas-dispatch-contract.test.mjs`;离线成功不代表实际 HTTP 生命周期通过。
|
||||
多目的地排车C52:`phase4-overseas-dispatch.mjs`在本轮实际收货产生的同仓同客户托盘上验证多FBA目的地及重复目的地托盘的增加、移除、原键重放和拒绝后零漂移。路线期望从真实Detail、Items、Route与冻结候选独立推导,记录`routeLifecycle`;缺少两个目的地及同目的地双托时明确阻断。使用版本化服务器草稿/容器/来源占用、`drafts/pre-arrange`转换、原生Create、`POST /{dispatchNo}/items/{dispatchItemId}/remove`和`PUT /{dispatchNo}/route`,不使用旧独立pre-arrange或DELETE items。正式交接保留原生三个读取对象及实际sourceKeys,停在ACTIVE/BEFORE_LOADING,未装车不得标记完成;实测缺失另行记录。写前原子落盘,未知结果停写,同marker不能重新创建。定向入口仍为`--modules phase4-overseas-dispatch`;真实运行会写指定TEST,仍须环境授权,失败保留原样本。两份对应合同验证脚本编排,不能证明服务端路线已正确同步或真实HTTP生命周期通过。
|
||||
|
||||
船节点异常 C66:`phase3-carrier-schedule-queries.mjs` 的 `captureCarrierExceptionEvidence` 与 `verifyCarrierExceptionTransition` 对本轮原船任务采集只读 checkpoint,核对 CUSTOMS_HOLD/TERMINAL_HOLD/DOCUMENT_HOLD/UNKNOWN_HOLD 的追加、解除、重放及冲突前后正常实际/预计节点守恒。`phase3-carrier-exceptions-contract.test.mjs` 只验证离线比对逻辑;真实生命周期还需要 Integration 供应商结果经调度器消费的独立投递证据。默认 HTTP 链只采基线并保留该覆盖缺口;空异常数组、重复 GET、报关异常和合成快照均不能计为供应商异常生命周期通过。
|
||||
|
||||
|
||||
+18
-10
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetBookingReservation } from './fleet-booking-reservation-acceptance.mjs'
|
||||
import { verifyFleetBookingReservation, testPlannedCapacity } from './fleet-booking-reservation-acceptance.mjs'
|
||||
import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const baseline = [
|
||||
@@ -71,16 +72,12 @@ function fixture(fault = '') {
|
||||
return result(booking)
|
||||
},
|
||||
}
|
||||
const measurements = (actual, stops) => {
|
||||
assert.deepEqual(actual, cargo); assert.deepEqual(stops, tripProof.stops)
|
||||
return cargo.map((row, index) => ({ warehousePalletId: row.warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: index + 1 }))
|
||||
}
|
||||
return { context, tripProof, measurements, calls, bookings, assertions }
|
||||
return { context, tripProof, cargo, calls, bookings, assertions }
|
||||
}
|
||||
|
||||
test('reservation HTTP helper verifies actual segments, original requests and cancellation without receiving or charging', async () => {
|
||||
const f = fixture()
|
||||
const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements)
|
||||
const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.bookings.length, 2)
|
||||
assert.ok(f.bookings.every(row => row.status === 'CANCELLED'))
|
||||
@@ -89,13 +86,14 @@ test('reservation HTTP helper verifies actual segments, original requests and ca
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(f.calls.every(row => !/incoming|receipt-confirmation|transfer-delivery|departure/.test(row.suffix)))
|
||||
assert.equal(proof.createRequest.customerId, '900719925474099301')
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements), /本轮新建/)
|
||||
assert.equal(proof.planningBasis.kind, 'CONTROLLED_TEST_PLAN'); assert.equal(proof.planningBasis.actual, false)
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof), /本轮新建/)
|
||||
})
|
||||
|
||||
for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-release', 'fake-loaded', 'missing-trace', 'wrong-conflict']) {
|
||||
test(`reservation helper fails closed on ${fault} even when HTTP reports success`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements))
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof))
|
||||
assert.equal(f.tripProof.reservation.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed && row.options.deploymentBlocker))
|
||||
if (['wrong-segment', 'replay-reoccupies'].includes(fault)) assert.equal(f.calls.some(row => row.suffix.endsWith('/cancel')), false)
|
||||
@@ -104,6 +102,16 @@ for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-rele
|
||||
|
||||
test('reservation helper rejects foreign fixture before making any request', async () => {
|
||||
const f = fixture()
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }, f.measurements), /本轮新建/)
|
||||
await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }), /本轮新建/)
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
|
||||
test('planned capacity counts a cross-customer physical pallet once and cannot be treated as actual measurement', () => {
|
||||
const f = fixture(), first = f.cargo[0]
|
||||
const planned = testPlannedCapacity([first, { ...first, customerId: 'OTHER-CUSTOMER' }], f.tripProof.stops)
|
||||
assert.equal(planned.length, 1); assert.equal(planned[0].warehousePalletId, first.warehousePalletId)
|
||||
assert.throws(() => controlledPalletMeasurements(planned, f.tripProof.stops), /实际预览|实际托盘/)
|
||||
assert.throws(() => testPlannedCapacity([first, { ...first, destinationKey: 'FBA:B' }], f.tripProof.stops), /不同计划卸货站/)
|
||||
assert.throws(() => testPlannedCapacity([{ ...first, warehousePalletId: null }], f.tripProof.stops), /缺少真实/)
|
||||
assert.throws(() => testPlannedCapacity([first], [{ name: 'Origin' }]), /唯一路线/)
|
||||
})
|
||||
|
||||
@@ -10,8 +10,23 @@ const version = value => /^(0|[1-9]\d*)$/.test(text(value))
|
||||
const trace = response => ({ http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
|
||||
/** 仅生成明确TEST计划占用;这些预计值不提供真实装载测量证据。 */
|
||||
export function testPlannedCapacity(cargo, stops) {
|
||||
const pallets = new Map()
|
||||
if (!Array.isArray(cargo) || !cargo.length || !Array.isArray(stops)) throw new Error('计划容量缺少本轮真实货物或路线')
|
||||
for (const row of cargo) {
|
||||
if (!row.warehousePalletId || !row.palletNo || !row.destinationKey) throw new Error('计划货物缺少真实实托和目的地')
|
||||
const matches = stops.map((stop, index) => stop.destinationKey === row.destinationKey ? index : -1).filter(index => index > 0)
|
||||
if (matches.length !== 1) throw new Error('计划货物目的地不在唯一路线卸货站')
|
||||
const palletId = text(row.warehousePalletId), deliveryIndex = matches[0]
|
||||
if (pallets.has(palletId) && pallets.get(palletId).deliveryIndex !== deliveryIndex) throw new Error('同一实托存在不同计划卸货站')
|
||||
pallets.set(palletId, { warehousePalletId: palletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex })
|
||||
}
|
||||
return [...pallets.values()]
|
||||
}
|
||||
|
||||
/** Internal HTTP helper. Operates only on the current run's trip before picking; no SQL or cleanup on failure. */
|
||||
export async function verifyFleetBookingReservation(context, module, tripProof, measurements) {
|
||||
export async function verifyFleetBookingReservation(context, module, tripProof) {
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof.tripNo || tripProof.reservation)
|
||||
throw new Error('外接占位验收仅接受本轮新建且尚未开始验收的车次;失败后保留原证据')
|
||||
const proof = tripProof.reservation = { status: 'CHECKING', marker: context.marker, requests: [], bookings: [] }
|
||||
@@ -37,7 +52,9 @@ export async function verifyFleetBookingReservation(context, module, tripProof,
|
||||
await check('自有货容量来源完整', initial?.cargo?.length > 0 && !initial.blockReason
|
||||
&& initial.cargo.every(row => row.customerId && row.warehousePalletId && row.destinationKey)
|
||||
&& version(initial.recordVersion) && /^[0-9a-f]{64}$/.test(initial.manifestHash || ''))
|
||||
const pallets = measurements(initial.cargo, tripProof.stops)
|
||||
const pallets = testPlannedCapacity(initial.cargo, tripProof.stops)
|
||||
proof.planningBasis = { kind: 'CONTROLLED_TEST_PLAN', actual: false, pallets,
|
||||
remark: '仅预计每实托1托位/100kg/1m³,用于计划余位测试;不得作为装车完成实测回执' }
|
||||
const plan = await request('POST', '/capacity-plan', {
|
||||
recordVersion: initial.recordVersion, manifestHash: initial.manifestHash, pallets,
|
||||
reason: '受控测试计划:按本轮真实实托确认预计占用,非生产测量',
|
||||
|
||||
@@ -1,71 +1,125 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { verifyFleetCrossCustomer, fixtureContract } from './fleet-cross-customer-acceptance.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { verifyFleetCrossCustomer, fixtureContract, continueFleetCrossCustomerAfterLoading, afterLoadingContinuationContract } from './fleet-cross-customer-acceptance.mjs'
|
||||
|
||||
function model(fault='') {
|
||||
const copy=value=>structuredClone(value),calls=[],keys=new Set()
|
||||
const members=[0,1].map(i=>({marker:'RUN',customerId:`C${i}`,incomingNo:`IN${i}`,dispatchNo:`DS${i}`,outboundNo:`OUT${i}`,deliveryNo:`DL${i}`,
|
||||
balanceId:`B${i}`,positionId:`P${i}`,palletId:`PAL${i}`,palletNo:`PALLET${i}`,receiptVerified:true}))
|
||||
const fixture={marker:'RUN',stage:fixtureContract.stage,tripNo:'TRIP',warehouseId:'WH',pdaToken:'not-a-real-token',members}
|
||||
const trip={trip:{tripNo:'TRIP',warehouseId:'WH',status:'PLANNED',recordVersion:'0',vehicleId:'V',driverId:'D',stops:[{name:'Origin'},
|
||||
{name:'A',destinationKey:'FBA:A'},{name:'B',destinationKey:'FBA:B'}]},dispatches:members.map(m=>({dispatchNo:m.dispatchNo,customerId:m.customerId,review:{status:'PENDING'}}))}
|
||||
const state=members.map(m=>({dispatch:{dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'PENDING_DEPARTURE',packageCount:1,pickedPackageCount:0,
|
||||
items:[{id:`ITEM${m.customerId}`,warehousePalletId:m.palletId}]},delivery:{order:{deliveryNo:m.deliveryNo,customerId:m.customerId,dispatchNo:m.dispatchNo,outboundNo:m.outboundNo,status:'WAIT_DELIVERY',version:'0'}},
|
||||
outbound:{outboundNo:m.outboundNo,dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'WAIT_OUTBOUND'},
|
||||
stock:{scope:{marker:'RUN',customerId:m.customerId,warehouseId:'WH',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'},
|
||||
balances:[{id:m.balanceId,customerId:m.customerId,overseasWarehouseId:'WH',availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}],
|
||||
positions:[{id:m.positionId,customerId:m.customerId,overseasWarehouseId:'WH',balanceId:m.balanceId,palletId:m.palletId,palletNo:m.palletNo,availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}],ledger:[]}}))
|
||||
const cargo=members.map((m,i)=>({dispatchNo:m.dispatchNo,customerId:m.customerId,warehousePalletId:m.palletId,palletNo:m.palletNo,destinationKey:i?'FBA:B':'FBA:A',packages:1}))
|
||||
const response=data=>({http:200,body:{code:'200',data:copy(data)},requestId:'request',traceId:'trace'})
|
||||
const context={marker:'RUN',token:'admin',createdResources:{},assert(_m,label,_r,condition){assert.ok(condition,label)},
|
||||
async request(method,raw,body,token){
|
||||
calls.push({method,raw,body:copy(body),token});const path=new URL(raw,'https://offline.invalid').pathname
|
||||
let index=members.findIndex(m=>path.includes(m.dispatchNo)||path.includes(m.outboundNo)||path.includes(m.deliveryNo))
|
||||
const row=state[index],member=members[index]
|
||||
if(path.endsWith('/review')){trip.dispatches[index].review.status='APPROVED';trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)}
|
||||
if(path.endsWith('/loading-review')){trip.loadingReview={status:'APPROVED'};trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)}
|
||||
if(path.endsWith('/loading')){if(method==='POST')trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response({recordVersion:trip.trip.recordVersion,manifestHash:'HASH',cargo,blockReason:null})}
|
||||
if(path.endsWith('/TRIP'))return response(trip)
|
||||
if(path.endsWith('/pick')){
|
||||
if(keys.has(body.idempotencyKey))return response({replayed:true})
|
||||
keys.add(body.idempotencyKey);row.dispatch.pickedPackageCount=1
|
||||
row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0'
|
||||
row.stock.ledger=['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:member.customerId,overseasWarehouseId:'WH',positionId:member.positionId,businessOrderNo:member.dispatchNo,businessType:type,deltaPackages:type==='SHIP'?'-1':'1'}))
|
||||
if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger[2],id:'EXTRA'})
|
||||
return response({replayed:false})
|
||||
}
|
||||
if(path.endsWith('/complete')){row.dispatch.status='READY_TO_DEPART';return response({status:'READY_TO_DEPART'})}
|
||||
if(path.endsWith('/depart')){
|
||||
if(state.some(s=>s.dispatch.pickedPackageCount!==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';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'}
|
||||
return {http:fault==='outage disguised as rejection'?503:200,body:{code:fault==='outage disguised as rejection'?'S0503':'A0409',message:fault==='unrelated gate rejection'?'车次版本已变化':'DS1:请完成拣货并核平全部箱数'},requestId:'r',traceId:'t'}
|
||||
function model(fault = '') {
|
||||
const copy = value => value == null ? value : structuredClone(value), calls = [], keys = new Map()
|
||||
const members = [0,1].map(i => ({ marker: 'RUN', customerId: `C${i}`, incomingNo: `IN${i}`, dispatchNo: 'DS-ONE', dispatchItemId: `ITEM${i}`,
|
||||
balanceId: `B${i}`, positionId: `P${i}`, palletId: `PAL${i}`, palletNo: `PALLET${i}`, receiptVerified: true }))
|
||||
const stops = [{ name: 'Origin' }, { name: 'A', destinationKey: 'FBA:A' }, { name: 'B', destinationKey: 'FBA:B' }]
|
||||
const tripProof = { marker: 'FT-CURRENT', dispatchNo: 'DS-ONE', tripNo: 'TRIP', warehouseId: 'WH', vehicleId: 'V', driverId: 'D', stops,
|
||||
reassignment: { status: 'PENDING' }, updateRequest: { plannedDepartureAt: new Date(Date.now()+3600000).toISOString() } }
|
||||
const actualEvidence = (field,value,i) => ({ field, value, actual: true, sourceType: 'WAREHOUSE_TRANSFER', sourceBusinessNo: `IN${i}`, observedAt: '2026-10-19T00:00:00Z' })
|
||||
const fixture = { marker: 'RUN', stage: fixtureContract.stage, dispatchNo: 'DS-ONE', tripNo: 'TRIP', tripProof, warehouseId: 'WH', pdaToken: 'not-a-real-token', members,
|
||||
loadingMeasurements: { marker: 'RUN', environment: 'TEST', dispatchNo: 'DS-ONE', pallets: members.map((m,i) => ({ warehousePalletId: m.palletId,
|
||||
palletSpaces: 1, deliveryIndex: i+1, evidence: [actualEvidence('palletSpaces',1,i)] })) } }
|
||||
const trip = { trip: { tripNo: 'TRIP', warehouseId: 'WH', status: 'PLANNED', recordVersion: '0', vehicleId: 'V', driverId: 'D', stops }, dispatches: [{ dispatchNo: 'DS-ONE' }] }
|
||||
let nativeVersion = 5, stage = 'LOADING', arranged = false, confirmedLoad = null, stopAtArrangement = false, aggregationReads = 4
|
||||
const state = members.map(m => ({ picked: 0, delivery: null, outbound: null,
|
||||
stock: { scope: { marker: 'RUN', customerId: m.customerId, warehouseId: 'WH', coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' },
|
||||
balances: [{ id: m.balanceId, customerId: m.customerId, overseasWarehouseId: 'WH', availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', inTransitPackages: '0', procurementPackages: '0' }],
|
||||
positions: [{ id: m.positionId, customerId: m.customerId, overseasWarehouseId: 'WH', balanceId: m.balanceId, palletId: m.palletId, palletNo: m.palletNo, availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0' }],
|
||||
ledger: [{ id: `STOCK-${m.customerId}`, customerId: m.customerId, overseasWarehouseId: 'WH', positionId: m.positionId, businessType: 'STOCK_IN', businessOrderNo: m.incomingNo, deltaPackages: '1' }] } }))
|
||||
const counts = () => ({ itemCount: 2, palletCount: 2, packageCount: 2, pickedPackageCount: state.reduce((n,s) => n+s.picked,0), shippedPackageCount: ['DELIVERING','DELIVERED'].includes(stage) ? 2 : 0 })
|
||||
const items = () => members.map((m,i) => ({ dispatchItemId: m.dispatchItemId, customerId: m.customerId, warehousePalletId: m.palletId,
|
||||
palletNo: m.palletNo, destination: { key: i ? 'FBA:B' : 'FBA:A' }, scheduledPackages: 1, pickedPackages: state[i].picked }))
|
||||
const deliveryRefs = () => state.filter(s => s.delivery).map((s,i) => ({ customerId: members[i].customerId, deliveryNo: s.delivery.order.deliveryNo,
|
||||
outboundNo: s.outbound.outboundNo, packageCount: 1, status: s.delivery.order.status, destination: { key: i ? 'FBA:B' : 'FBA:A' } }))
|
||||
const detail = () => ({ dispatchNo: 'DS-ONE', status: 'ACTIVE', stage, recordVersion: nativeVersion, reviewState: 'APPROVED',
|
||||
reviews: { dispatchReview: { status: 'APPROVED' }, loadingReview: { status: 'APPROVED' } }, counts: counts(),
|
||||
documents: { deliveries: deliveryRefs(), outbounds: state.filter(s=>s.outbound).map(s=>s.outbound) } })
|
||||
const result = (body,payload,replayed = false) => ({ dispatchNo: 'DS-ONE', status: 'ACTIVE', stage, recordVersion: nativeVersion,
|
||||
counts: counts(), idempotencyKey: body.idempotencyKey, result: payload, replayed })
|
||||
const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
||||
const reject = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
|
||||
const forbidden = () => ({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员', requestId: 'request', traceId: 'trace' }, requestId: 'request', traceId: 'trace' })
|
||||
const remember = (body,payload,actor,action) => { const value = {...result(body,payload),...(action?{action}:{})}; keys.set(body.idempotencyKey,{body:copy(body),result:copy(value),actor});return response(value) }
|
||||
const context = { marker: 'RUN', token: 'admin', createdResources: { fleetTrips: [tripProof] }, assert(_m,label,_r,condition) { assert.ok(condition,label) },
|
||||
apiSuccess(_m,_l,r) { return r.body.data },
|
||||
async request(method,raw,body,token) {
|
||||
calls.push({method,raw,body:copy(body),token}); const url = new URL(raw,'https://offline.invalid'), path = url.pathname
|
||||
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'})
|
||||
return forbidden()
|
||||
}
|
||||
const replayed=keys.has(body.idempotencyKey);keys.add(body.idempotencyKey);trip.trip.status='IN_TRANSIT'
|
||||
for(const [i,s] of state.entries())if(!(fault==='partial departure'&&i===1)){s.dispatch.status='DEPARTED';s.outbound.status='OUTBOUNDED'}
|
||||
if(fault==='depart deducts again'){row.stock.balances[0].availablePackages='-1';row.stock.positions[0].availablePackages='-1'}
|
||||
return response({tripNo:'TRIP',status:'IN_TRANSIT',shippedPackages:'2',dispatches:members.map(m=>({dispatchNo:m.dispatchNo})),replayed})
|
||||
return response({...prior.result,replayed:true})
|
||||
}
|
||||
if(path.includes('/api/pda/'))return response({dispatchNo:member.dispatchNo,warehouseId:'WH',tripNo:'TRIP',status:row.dispatch.pickedPackageCount?'COMPLETED':'PDA_PENDING',
|
||||
items:[{dispatchItemId:`ITEM${member.customerId}`,palletNo:member.palletNo,scheduledPackages:1,pickedPackages:String(row.dispatch.pickedPackageCount),
|
||||
positions:fault==='missing PDA position'?[]:[{id:member.positionId,balanceId:member.balanceId,availablePackages:row.stock.positions[0].availablePackages}]}]})
|
||||
if(path.includes('/dispatches/'))return response(row.dispatch)
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()})
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') {
|
||||
if(method==='PUT')return reject('已经真实扫码,不能编辑资源')
|
||||
if(fault==='aggregation delayed'&&state.every(s=>s.delivery?.order.status==='DELIVERED')) {
|
||||
if(aggregationReads===0)stage='DELIVERED';else aggregationReads--
|
||||
}
|
||||
return response(detail())
|
||||
}
|
||||
if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST',
|
||||
pickingReconciled:state.every(s=>s.picked===1),confirmedLoad,loadingComplete:{allowed:state.every(s=>s.picked===1)},pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletNo:m.palletNo,
|
||||
scheduledPackages:1,pickedPackages:state[i].picked,palletSpaces:null,weightKg:100,volumeCbm:1,deliveryIndex:i+1,
|
||||
evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))})
|
||||
if (path.endsWith('/loading-complete')) {
|
||||
if (!state.every(s=>s.picked===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++
|
||||
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')
|
||||
}
|
||||
if (path.endsWith('/pick')) {
|
||||
const index=members.findIndex(m=>path.includes(`/items/${m.dispatchItemId}/`)),m=members[index],row=state[index]
|
||||
assert.equal(body.recordVersion,nativeVersion);assert.equal(body.positionId,m.positionId);row.picked=1;nativeVersion++
|
||||
row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0'
|
||||
row.stock.ledger.push(...['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:m.customerId,overseasWarehouseId:'WH',positionId:m.positionId,businessOrderNo:'DS-ONE',businessType:type,deltaPackages:type==='SHIP'?'-1':'1'})))
|
||||
if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger.at(-1),id:'EXTRA'})
|
||||
return remember(body,{dispatchItemId:m.dispatchItemId,pickedPackages:1},token)
|
||||
}
|
||||
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('/status')){row.delivery.order.status='DELIVERING';row.delivery.order.version=String(Number(row.delivery.order.version)+1);return response({deliveryStatus:'DELIVERING'})}
|
||||
if(path.endsWith('/documents')){
|
||||
if(path.endsWith('/documents')) {
|
||||
if(method==='GET')return response({version:row.delivery.order.version,categories:[]})
|
||||
row.delivery.order.status='DELIVERED';row.delivery.order.version=String(Number(row.delivery.order.version)+1)
|
||||
row.delivery.order.status='DELIVERED';row.delivery.order.version++
|
||||
if(fault==='signs both')state[1-index].delivery.order.status='DELIVERED'
|
||||
if(state.every(s=>s.delivery.order.status==='DELIVERED')&&!['aggregation delayed','aggregation never completes'].includes(fault))stage='DELIVERED'
|
||||
return response({version:row.delivery.order.version})
|
||||
}
|
||||
if(path.includes('/delivery-orders/'))return response(row.delivery)
|
||||
if(path.includes('/delivery-orders/')) {assert.equal(url.searchParams.get('customerId'),members[index].customerId);return response(row.delivery)}
|
||||
throw new Error(`Unexpected ${method} ${path}`)
|
||||
},async requestMultipart(method,path,body){calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})}}
|
||||
},async requestMultipart(method,path) {calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})} }
|
||||
const readInventory=async scope=>{const value=copy(state[members.findIndex(m=>m.customerId===scope.customerId)].stock);if(fault==='wrong stock scope')value.positions[0].customerId='OTHER';return value}
|
||||
return {context,fixture,readInventory,calls,state}
|
||||
return {context,fixture,readInventory,calls,state,stopAfterLoading(){stopAtArrangement=true},resumeAfterLoading(){stopAtArrangement=false}}
|
||||
}
|
||||
|
||||
test('default call and import do not request, create or activate anything',async()=>{
|
||||
let called=false;const result=await verifyFleetCrossCustomer({request(){called=true}},{})
|
||||
assert.equal(result,fixtureContract);assert.equal(called,false)
|
||||
@@ -73,17 +127,29 @@ 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,3)
|
||||
assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,4)
|
||||
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))
|
||||
assert.equal(new Set(proof.members.map(row=>row.dispatchNo)).size,1)
|
||||
assert.equal(new Set(proof.members.map(row=>row.deliveryNo)).size,2)
|
||||
assert.equal(new Set(proof.members.map(row=>row.outboundNo)).size,2)
|
||||
assert.ok(!m.calls.some(c=>c.raw.endsWith('/complete')||c.raw.includes('/dispatches/bind')))
|
||||
})
|
||||
|
||||
test('missing physical pallet-space evidence blocks after both real picks without fabricating loading or delivery',async()=>{
|
||||
const m=model();delete m.fixture.loadingMeasurements
|
||||
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/palletSpaces/)
|
||||
assert.ok(m.state.every(row=>row.picked===1&&row.delivery===null))
|
||||
assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1,'only the earlier expected unready rejection was sent')
|
||||
assert.equal(m.calls.some(call=>call.raw.endsWith('/transfer-delivery')),false)
|
||||
})
|
||||
test('explicit rollback verifier runs after both picks and before successful departure',async()=>{
|
||||
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.ok(ready.members.every(row=>row.dispatch.status==='READY_TO_DEPART'))
|
||||
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')
|
||||
return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'}
|
||||
@@ -101,7 +167,7 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati
|
||||
assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1)
|
||||
assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY'))
|
||||
})
|
||||
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']) {
|
||||
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()=>{
|
||||
const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),
|
||||
fault==='gate reorders route'?/Rejected departure leaves both complete inventories\/outbounds\/deliveries\/trip unchanged/:undefined)
|
||||
@@ -118,5 +184,152 @@ test('missing reader, reused customer and stale marker reject before any API req
|
||||
test('module contains no autonomous runner or SQL/environment/credential plumbing',async()=>{
|
||||
const source=await readFile(new URL('./fleet-cross-customer-acceptance.mjs',import.meta.url),'utf8')
|
||||
assert.doesNotMatch(source,/process\.env|fetch\(|execSync|INSERT INTO|UPDATE ll_|createPicker|createMerchant/)
|
||||
assert.match(source,/controlledPalletMeasurements/)
|
||||
assert.match(source,/confirmFleetLoading/)
|
||||
})
|
||||
|
||||
async function afterLoadingModel(fault = '') {
|
||||
const m = model(fault)
|
||||
m.stopAfterLoading()
|
||||
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/Explicit test checkpoint after confirmed loading/)
|
||||
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')
|
||||
delete m.context.createdResources.fleetCrossCustomer
|
||||
m.fixture.stage = afterLoadingContinuationContract.stage
|
||||
m.resumeAfterLoading()
|
||||
m.calls.length = 0
|
||||
return {...m,priorProof}
|
||||
}
|
||||
|
||||
const continueLoaded = (m,extra = {}) => continueFleetCrossCustomerAfterLoading(m.context,m.fixture,
|
||||
{execute:true,readInventory:m.readInventory,priorProof:m.priorProof,completionPolling:{maxAttempts:8,intervalMs:1,deadlineMs:1000},...extra})
|
||||
const writes = m => m.calls.filter(call=>!['GET','HEAD','OPTIONS'].includes(call.method))
|
||||
function alterRead(m,match,alter) {
|
||||
const original = m.context.request
|
||||
m.context.request = async function(...args) {
|
||||
const response = await original.apply(this,args)
|
||||
if(args[0]==='GET'&&match(args[1]))alter(response.body.data)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
test('explicit after-loading import/default stays offline and is not a fallback of the fresh entry',async()=>{
|
||||
let called=false
|
||||
assert.equal(await continueFleetCrossCustomerAfterLoading({request(){called=true}},{}),afterLoadingContinuationContract)
|
||||
assert.equal(called,false)
|
||||
const m=await afterLoadingModel()
|
||||
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}))
|
||||
assert.equal(m.calls.length,0)
|
||||
})
|
||||
|
||||
test('explicit after-loading continuation reconciles original proof and never repeats picking or loading',async()=>{
|
||||
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.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
|
||||
assert.equal(proof.continuation.priorProofSHA256,createHash('sha256').update(JSON.stringify(normalize(original))).digest('hex'))
|
||||
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.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()
|
||||
if(failure==='missing prior')m.priorProof=undefined
|
||||
if(failure==='marker')m.priorProof.marker='OTHER-RUN'
|
||||
if(failure==='gate')m.priorProof.gateProof='NOT_RUN'
|
||||
if(failure==='source identity')m.priorProof.members[0].positionId='FOREIGN-POSITION'
|
||||
if(failure==='prior picking')m.priorProof.snapshots.AFTER_PICK_1.dispatch.counts.pickedPackageCount=1
|
||||
if(failure==='unknown prior write')m.priorProof.requests.push({method:'POST',path:'/unconfirmed-command',status:'UNKNOWN_RESULT'})
|
||||
if(failure==='loading result')delete m.fixture.tripProof.loadingCommand.result
|
||||
if(failure==='loading manifest')m.fixture.tripProof.loadingCommand.body.manifestHash='OTHER-MANIFEST'
|
||||
if(failure==='loading pallet')m.fixture.tripProof.loadingCommand.result.result.confirmedLoad.pallets[0].warehousePalletId='OTHER-PALLET'
|
||||
if(failure==='loading version')m.fixture.tripProof.loadingCommand.body.recordVersion='1'
|
||||
await assert.rejects(continueLoaded(m));assert.equal(m.calls.length,0)
|
||||
})
|
||||
}
|
||||
|
||||
for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity','manifest drift','confirmed load drift','existing arrangement']) {
|
||||
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'})
|
||||
if(failure==='current main version')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{value.recordVersion=999})
|
||||
if(failure==='current shipped')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{value.counts.shippedPackageCount=1})
|
||||
if(failure==='existing outbound')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{if(value.documents)value.documents.outbounds.push({outboundNo:'EXISTING'})})
|
||||
if(failure==='current source identity')alterRead(m,path=>path.endsWith('/items'),value=>{value.items[0].warehousePalletId='FOREIGN'})
|
||||
if(failure==='manifest drift')alterRead(m,path=>path.endsWith('/loading'),value=>{value.manifestHash='OTHER'})
|
||||
if(failure==='confirmed load drift')alterRead(m,path=>path.endsWith('/loading'),value=>{value.confirmedLoad.pallets[0].weightKg=999})
|
||||
if(failure==='existing arrangement')alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={status:'ARRANGED'}})
|
||||
await assert.rejects(continueLoaded(m));assert.equal(writes(m).length,0)
|
||||
})
|
||||
}
|
||||
|
||||
test('read-only aggregation polling accepts delayed completion without repeating receipt uploads or signatures',async()=>{
|
||||
const m=await afterLoadingModel('aggregation delayed')
|
||||
const proof=await continueLoaded(m)
|
||||
assert.equal(proof.completionPolling.status,'COMPLETED')
|
||||
assert.ok(proof.completionPolling.attempts.length>1&&proof.completionPolling.attempts.length<=8)
|
||||
assert.ok(proof.completionPolling.attempts.every(entry=>entry.status==='RESPONDED'))
|
||||
assert.equal(m.calls.filter(call=>call.multipart).length,2)
|
||||
assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2)
|
||||
})
|
||||
|
||||
test('read-only aggregation polling times out as a real failure without changing the dispatch stage',async()=>{
|
||||
const m=await afterLoadingModel('aggregation never completes')
|
||||
await assert.rejects(continueLoaded(m,{completionPolling:{maxAttempts:3,intervalMs:1,deadlineMs:1000}}),/Automatic delivery completion/)
|
||||
const proof=m.context.createdResources.fleetCrossCustomer
|
||||
assert.equal(proof.status,'FAILED');assert.equal(proof.completionPolling.status,'TIMED_OUT')
|
||||
assert.equal(proof.completionPolling.attempts.length,3)
|
||||
assert.ok(proof.completionPolling.attempts.every(entry=>entry.stage==='DELIVERING'))
|
||||
assert.equal(m.calls.filter(call=>call.multipart).length,2)
|
||||
assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2)
|
||||
})
|
||||
|
||||
for(const failure of ['permission','structure'])test(`completion polling terminates immediately on ${failure} errors`,async()=>{
|
||||
const m=await afterLoadingModel(),original=m.context.request
|
||||
let reads=0
|
||||
m.context.request=async function(...args){
|
||||
const response=await original.apply(this,args)
|
||||
if(args[0]==='GET'&&args[1]==='/api/platform/v1/last-leg/dispatches/DS-ONE'
|
||||
&&m.context.createdResources.fleetCrossCustomer?.completionPolling?.status==='WAITING') {
|
||||
reads++
|
||||
if(failure==='permission')return {http:403,body:{code:'A0403'},requestId:'request',traceId:'trace'}
|
||||
response.body.data.documents={outbounds:[],deliveries:[]}
|
||||
}
|
||||
return response
|
||||
}
|
||||
await assert.rejects(continueLoaded(m))
|
||||
assert.equal(reads,1);assert.equal(m.context.createdResources.fleetCrossCustomer.completionPolling.status,'FAILED')
|
||||
assert.equal(m.calls.filter(call=>call.multipart).length,2)
|
||||
assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const MODULE = 'fleet-cross-customer-acceptance'
|
||||
const ROOT = '/api/platform/v1/last-leg'
|
||||
@@ -10,57 +11,82 @@ const encode = encodeURIComponent
|
||||
const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}`
|
||||
const canonical = value => JSON.stringify(normalize(value))
|
||||
function normalize(value) {
|
||||
// Route stops and other business arrays are ordered. Raw readers return stable ID order.
|
||||
if (Array.isArray(value)) return value.map(normalize)
|
||||
return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])) : value
|
||||
return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value
|
||||
}
|
||||
function integer(value) {
|
||||
if (!['number','string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer')
|
||||
if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer')
|
||||
return Number(value)
|
||||
}
|
||||
const buckets = ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']
|
||||
const balanceBuckets = [...buckets,'inTransitPackages','procurementPackages']
|
||||
|
||||
const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
|
||||
const balanceBuckets = [...buckets, 'inTransitPackages', 'procurementPackages']
|
||||
export const fixtureContract = Object.freeze({
|
||||
stage: 'TWO_FRESH_RECEIVED_DISPATCHES_BOUND_AND_TRANSFERRED_NOT_PICKED',
|
||||
fixture: 'marker, warehouseId, tripNo, pdaToken, members[2]: {marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified:true}',
|
||||
preparation: 'Two independently qualified current-run customers; each formally received one physical pallet/box in the same warehouse; draft lock/pre-arrange separately; bind both to one assigned qualified vehicle/driver trip; transfer each to its own outbound and delivery. No picking yet.',
|
||||
reader: 'readInventory(scope) => {scope:{marker,customerId,warehouseId,coverage:"CUSTOMER_WAREHOUSE_COMPLETE"}, balances:all raw rows including zero, positions:all raw rows including zero, ledger:all immutable movement rows}. Retain camelCase IDs and every quantity bucket; never substitute a grouped public SKU page.',
|
||||
helperLimit: 'Existing external-receipt helper selects plan.cargo[0].customerId and requires an existing own-cargo capacity plan; external-dispatch helper has one mutable externalReceipt slot. Do not run either twice pretending to create two customers.',
|
||||
boundary: 'This exercises a prerequisite gate rejection, not a mid-transaction rollback. No fault injection, account activation, SQL writes, deployment, cleanup deletion or implicit fixture creation.',
|
||||
stage: 'TWO_FRESH_CUSTOMERS_ONE_REVIEWED_DISPATCH_LOADING_NOT_PICKED',
|
||||
fixture: 'marker, warehouseId, dispatchNo, tripNo, tripProof, pdaToken, optional current-run loadingMeasurements; members[2]: marker/customerId/incomingNo/shared dispatchNo/dispatchItemId/balanceId/positionId/palletId/palletNo/receiptVerified',
|
||||
preparation: 'Two independently qualified customers formally received one pallet/box each in the same warehouse. One native Create with both actual source selections; automatic unique Fleet record, resources and both native reviews. No picking or delivery arrangement.',
|
||||
reader: 'readInventory(scope) returns all raw balances/positions/immutable movements including zero, exact marker/customer/warehouse and CUSTOMER_WAREHOUSE_COMPLETE coverage.',
|
||||
boundary: 'Prerequisite rejection is separate from an explicitly injected real mid-transaction rollback. Actual loading needs actual evidence; absent measurement fields block. No environment loader, SQL or implicit account lifecycle.',
|
||||
})
|
||||
|
||||
/** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution.
|
||||
* Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys.
|
||||
*/
|
||||
export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback } = {}) {
|
||||
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.',
|
||||
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.',
|
||||
})
|
||||
|
||||
export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback, completionPolling } = {}) {
|
||||
if (!execute) return fixtureContract
|
||||
if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage
|
||||
|| !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function'
|
||||
|| fixture.members?.length !== 2 || new Set(fixture.members.map(row=>row.customerId)).size !== 2
|
||||
|| fixture.members.some(row=>row.marker!==context.marker || row.receiptVerified!==true
|
||||
|| ['customerId','incomingNo','dispatchNo','outboundNo','deliveryNo','balanceId','positionId','palletId','palletNo'].some(key=>!id(row[key])))) {
|
||||
throw new Error('Requires two distinct current-run customers, immutable receipt references and a complete raw inventory reader')
|
||||
}
|
||||
for (const key of ['dispatchNo','outboundNo','deliveryNo','positionId','palletId']) {
|
||||
if (new Set(fixture.members.map(row=>id(row[key]))).size!==2) throw new Error(`Cross-customer fixture reuses ${key}`)
|
||||
}
|
||||
context.createdResources ||= {}
|
||||
return runCrossCustomerCore(context, fixture, { readInventory, verifyDepartureRollback, completionPolling })
|
||||
}
|
||||
|
||||
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')
|
||||
return runCrossCustomerCore(context, { ...fixture, members: structuredClone(fixture?.members) },
|
||||
{ readInventory, verifyDepartureRollback, priorProof, completionPolling })
|
||||
}
|
||||
|
||||
async function runCrossCustomerCore(context, fixture, { readInventory, verifyDepartureRollback, priorProof, completionPolling }) {
|
||||
const continuation = priorProof !== undefined
|
||||
const contract = continuation ? afterLoadingContinuationContract : fixtureContract
|
||||
if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== contract.stage
|
||||
|| !fixture.dispatchNo || !fixture.tripNo || fixture.tripProof?.dispatchNo !== fixture.dispatchNo || fixture.tripProof.tripNo !== fixture.tripNo
|
||||
|| !context.createdResources?.fleetTrips?.includes(fixture.tripProof)
|
||||
|| !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' || fixture.members?.length !== 2
|
||||
|| new Set(fixture.members.map(row => row.customerId)).size !== 2
|
||||
|| fixture.members.some(row => row.marker !== context.marker || row.receiptVerified !== true || row.dispatchNo !== fixture.dispatchNo
|
||||
|| ['customerId', 'incomingNo', 'dispatchItemId', 'balanceId', 'positionId', 'palletId', 'palletNo'].some(field => !id(row[field]))))
|
||||
throw new Error('Requires two distinct current-run customers and one reviewed native dispatch with complete raw inventory evidence')
|
||||
for (const field of ['incomingNo', 'dispatchItemId', 'positionId', 'palletId', 'balanceId'])
|
||||
if (new Set(fixture.members.map(row => id(row[field]))).size !== 2) throw new Error(`Cross-customer fixture reuses ${field}`)
|
||||
const polling = { maxAttempts: 41, intervalMs: 500, deadlineMs: 20000, ...completionPolling }
|
||||
if (!Number.isSafeInteger(polling.maxAttempts) || polling.maxAttempts < 1 || polling.maxAttempts > 121
|
||||
|| !Number.isSafeInteger(polling.intervalMs) || polling.intervalMs < 1 || polling.intervalMs > 2000
|
||||
|| !Number.isSafeInteger(polling.deadlineMs) || polling.deadlineMs < 1 || polling.deadlineMs > 60000)
|
||||
throw new Error('Finite completion polling bounds are required')
|
||||
context.createdResources ??= {}
|
||||
if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly')
|
||||
const proof = context.createdResources.fleetCrossCustomer = { marker:context.marker,tripNo:fixture.tripNo,
|
||||
warehouseId:fixture.warehouseId,members:fixture.members.map(({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})=>({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})),
|
||||
status:'CHECKING',requests:[],snapshots:{},gateProof:'NOT_RUN',midTransactionRollback:'NOT_TESTED',cleanup:'Caller owns account/session lifecycle; retain all business fixtures' }
|
||||
const check = (name, passed) => {
|
||||
context.assert(MODULE,name,null,Boolean(passed),'',OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const proof = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo,
|
||||
warehouseId: fixture.warehouseId, members: fixture.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo }) =>
|
||||
({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo })),
|
||||
status: 'CHECKING', requests: [], snapshots: {}, gateProof: 'NOT_RUN', midTransactionRollback: 'NOT_TESTED', cleanup: 'Caller owns account/session lifecycle; retain all business fixtures' }
|
||||
const check = (name, passed) => { context.assert(MODULE, name, null, Boolean(passed), '', OPTIONS); if (!passed) throw new Error(name) }
|
||||
const equal = (a, b, name) => check(name, canonical(a) === canonical(b))
|
||||
const key = suffix => `${context.marker}-CROSS-${suffix}`
|
||||
const path = `${ROOT}/dispatches/${encode(fixture.dispatchNo)}`, pdaPath = `${PDA}/${encode(fixture.dispatchNo)}`
|
||||
const tripPath = `${ROOT}/fleet/trips/${encode(fixture.tripNo)}`
|
||||
const request = async (method,path,body,label,pda=false,rejection=false) => {
|
||||
const response = await context.request(method,path,body,pda?fixture.pdaToken:context.token,{})
|
||||
proof.requests.push({method,path,body,label,http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId})
|
||||
const denied = response?.http===409 || (response?.http===200 && id(response.body?.code)==='A0409')
|
||||
check(label,Boolean(response?.requestId && response?.traceId) && (rejection?denied && rejection(id(response.body?.message)):ok(response)))
|
||||
const request = async (method, target, body, label, pda = false, rejection) => {
|
||||
const entry = { method, path: target, body, label, status: 'SENDING' }; proof.requests.push(entry); await context.checkpoint?.()
|
||||
let response
|
||||
try { response = await context.request(method, target, body, pda ? fixture.pdaToken : context.token, {}) }
|
||||
catch (error) { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint?.(); throw error }
|
||||
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await context.checkpoint?.()
|
||||
const denied = response?.http === 409 || (response?.http === 200 && id(response.body?.code) === 'A0409')
|
||||
const actorDenied = [200,403].includes(response?.http) && id(response.body?.code) === 'A0403'
|
||||
check(label, Boolean(response?.requestId && response?.traceId)
|
||||
&& (rejection === 'ACTOR_FORBIDDEN' ? actorDenied : rejection ? denied && rejection(id(response.body?.message)) : ok(response)))
|
||||
return response.body?.data
|
||||
}
|
||||
const stock = async member => {
|
||||
@@ -81,120 +107,273 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal
|
||||
return raw
|
||||
}
|
||||
const snapshot = async label => {
|
||||
const trip=await request('GET',tripPath,undefined,'Read shared trip')
|
||||
check('Trip members are exactly the two independent customers',trip.trip?.tripNo===fixture.tripNo && id(trip.trip.warehouseId)===id(fixture.warehouseId)
|
||||
&& trip.dispatches?.length===2 && fixture.members.every(member=>trip.dispatches.some(row=>row.dispatchNo===member.dispatchNo && id(row.customerId)===id(member.customerId))))
|
||||
const members=[]
|
||||
for(const member of fixture.members) {
|
||||
const dispatch=await request('GET',`${ROOT}/dispatches/${encode(member.dispatchNo)}`,undefined,'Read customer dispatch')
|
||||
const outbound=await request('GET',scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`,member.customerId),undefined,'Read customer outbound')
|
||||
const delivery=await request('GET',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId),undefined,'Read customer delivery')
|
||||
const task=await request('GET',`${PDA}/${encode(member.dispatchNo)}`,undefined,'Read PDA task',true)
|
||||
check('Execution records retain exact customer, warehouse and dispatch ownership',dispatch.dispatchNo===member.dispatchNo
|
||||
&& id(dispatch.overseasWarehouseId)===id(fixture.warehouseId) && dispatch.items?.length===1
|
||||
&& id(dispatch.items[0].warehousePalletId)===id(member.palletId)
|
||||
&& outbound.outbound?.outboundNo===member.outboundNo && outbound.outbound.dispatchNo===member.dispatchNo
|
||||
&& id(outbound.outbound.overseasWarehouseId)===id(fixture.warehouseId) && outbound.deliveries?.length===1
|
||||
&& outbound.deliveries[0].deliveryNo===member.deliveryNo && id(outbound.deliveries[0].customerId)===id(member.customerId)
|
||||
&& delivery.order?.deliveryNo===member.deliveryNo && id(delivery.order.customerId)===id(member.customerId)
|
||||
&& delivery.order.dispatchNo===member.dispatchNo && delivery.order.outboundNo===member.outboundNo
|
||||
&& task.dispatchNo===member.dispatchNo && id(task.warehouseId)===id(fixture.warehouseId) && task.tripNo===fixture.tripNo)
|
||||
members.push({dispatch,outbound,delivery,task,stock:await stock(member)})
|
||||
const trip = await request('GET', tripPath, undefined, 'Read automatic unique Fleet resource record')
|
||||
check('One trip binds exactly this one native cross-customer dispatch', trip.trip?.tripNo === fixture.tripNo
|
||||
&& id(trip.trip.warehouseId) === id(fixture.warehouseId) && trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === fixture.dispatchNo)
|
||||
const dispatch = await request('GET', path, undefined, 'Read native whole dispatch')
|
||||
const items = await request('GET', `${path}/items`, undefined, 'Read actual per-customer cargo')
|
||||
const task = await request('GET', pdaPath, undefined, 'Read bound warehouse native PDA task', true)
|
||||
check('Native main/items/PDA retain both true customers and physical pallets', dispatch.dispatchNo === fixture.dispatchNo
|
||||
&& items.dispatchNo === fixture.dispatchNo && task.dispatchNo === fixture.dispatchNo && id(task.warehouseId) === id(fixture.warehouseId)
|
||||
&& id(dispatch.recordVersion) === id(items.recordVersion) && id(task.recordVersion) === id(dispatch.recordVersion)
|
||||
&& items.items?.length === 2 && task.items?.length === 2 && fixture.members.every(member =>
|
||||
items.items.some(i => id(i.dispatchItemId) === member.dispatchItemId && id(i.customerId) === member.customerId
|
||||
&& id(i.warehousePalletId) === member.palletId && i.palletNo === member.palletNo && integer(i.scheduledPackages) === 1)
|
||||
&& task.items.some(i => id(i.dispatchItemId) === member.dispatchItemId && id(i.customerId) === member.customerId && i.palletNo === member.palletNo)))
|
||||
const members = []
|
||||
for (const member of proof.members) {
|
||||
let outbound = null, delivery = null
|
||||
if (member.outboundNo || member.deliveryNo) {
|
||||
check('Customer execution documents are both explicit', member.outboundNo && member.deliveryNo)
|
||||
outbound = await request('GET', scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`, member.customerId), undefined, 'Read customer outbound')
|
||||
delivery = await request('GET', scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`, member.customerId), undefined, 'Read customer delivery')
|
||||
check('Execution documents keep customer, warehouse and shared main ownership', outbound.outbound?.outboundNo === member.outboundNo
|
||||
&& outbound.outbound.dispatchNo === fixture.dispatchNo && id(outbound.outbound.overseasWarehouseId) === id(fixture.warehouseId)
|
||||
&& outbound.deliveries?.length === 1 && outbound.deliveries[0].deliveryNo === member.deliveryNo
|
||||
&& id(outbound.deliveries[0].customerId) === member.customerId && delivery.order?.deliveryNo === member.deliveryNo
|
||||
&& id(delivery.order.customerId) === member.customerId && delivery.order.dispatchNo === fixture.dispatchNo && delivery.order.outboundNo === member.outboundNo)
|
||||
}
|
||||
members.push({ outbound, delivery, stock: await stock(member) })
|
||||
}
|
||||
const value={trip,members};proof.snapshots[label]=value;return value
|
||||
const value = { trip, dispatch, items, task, members }; proof.snapshots[label] = value; return value
|
||||
}
|
||||
const equal = (a,b,name)=>check(name,canonical(a)===canonical(b))
|
||||
const pick = async (member,index) => {
|
||||
const before=await snapshot(`BEFORE_PICK_${index}`),task=before.members[index].task
|
||||
check('PDA exposes the single real current-run pallet and position',task.items?.length===1 && integer(task.items[0].scheduledPackages)===1
|
||||
&& integer(task.items[0].pickedPackages)===0 && task.items[0].palletNo===member.palletNo && task.items[0].positions?.length===1
|
||||
&& id(task.items[0].positions[0].id)===id(member.positionId) && integer(task.items[0].positions[0].availablePackages)===1)
|
||||
const body={positionId:member.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker}
|
||||
const path=`${PDA}/${encode(member.dispatchNo)}/items/${encode(task.items[0].dispatchItemId)}/pick`
|
||||
await request('POST',path,body,'Pick this customer only',true)
|
||||
await request('POST',`${PDA}/${encode(member.dispatchNo)}/complete`,{idempotencyKey:key(`COMPLETE-${index}`)},'Complete this customer picking',true)
|
||||
const after=await snapshot(`AFTER_PICK_${index}`)
|
||||
equal(before.members[1-index].stock,after.members[1-index].stock,'Other customer inventory unchanged by picking')
|
||||
const expected=structuredClone(before.members[index].stock)
|
||||
for(const collection of ['balances','positions']) {
|
||||
const row=expected[collection].find(row=>id(row.id)===id(collection==='balances'?member.balanceId:member.positionId))
|
||||
row.availablePackages=integer(row.availablePackages)-1
|
||||
}
|
||||
const counts=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries((collection==='balances'?balanceBuckets:buckets).map(field=>[field,integer(row[field])]))}))]))
|
||||
equal(counts(expected),counts(after.members[index].stock),'Only selected customer position and balance lose one box')
|
||||
const old=before.members[index].stock.ledger,newRows=after.members[index].stock.ledger.filter(row=>!old.some(prior=>id(prior.id)===id(row.id)))
|
||||
equal(old,after.members[index].stock.ledger.filter(row=>old.some(prior=>id(prior.id)===id(row.id))),'Immutable old movements preserved')
|
||||
check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity',newRows.length===3 && ['ALLOCATE','PICK','SHIP'].every(type=>newRows.filter(row=>row.businessType===type
|
||||
&& id(row.positionId)===id(member.positionId) && row.businessOrderNo===member.dispatchNo && Number(row.deltaPackages)===(type==='SHIP'?-1:1)).length===1))
|
||||
await request('POST',path,body,'Replay exact customer pick',true)
|
||||
equal(after,await snapshot(`PICK_REPLAY_${index}`),'Pick replay changes neither customer nor any execution record')
|
||||
const pick = async (member, index) => {
|
||||
const before = await snapshot(`BEFORE_PICK_${index}`), item = before.task.items.find(i => id(i.dispatchItemId) === member.dispatchItemId)
|
||||
check('PDA exposes exactly this customer real current-run pallet and position', integer(item.scheduledPackages) === 1 && integer(item.pickedPackages) === 0
|
||||
&& item.palletNo === member.palletNo && item.positions?.length === 1 && id(item.positions[0].positionId) === member.positionId
|
||||
&& integer(item.positions[0].availablePackages) === 1)
|
||||
const body = { recordVersion: before.task.recordVersion, positionId: member.positionId, packages: 1, productQuantity: 1,
|
||||
idempotencyKey: key(`PICK-${index}`), remark: context.marker }
|
||||
const pickPath = `${pdaPath}/items/${encode(member.dispatchItemId)}/pick`
|
||||
await request('POST', pickPath, body, 'Pick this customer only', true)
|
||||
const after = await snapshot(`AFTER_PICK_${index}`)
|
||||
equal(before.members[1-index].stock, after.members[1-index].stock, 'Other customer inventory unchanged by picking')
|
||||
const expected = structuredClone(before.members[index].stock)
|
||||
for (const collection of ['balances', 'positions']) expected[collection].find(row => id(row.id) === (collection === 'balances' ? member.balanceId : member.positionId)).availablePackages = 0
|
||||
const counts = raw => Object.fromEntries(['balances','positions'].map(collection => [collection, raw[collection].map(row => ({ id: id(row.id),
|
||||
...Object.fromEntries((collection === 'balances' ? balanceBuckets : buckets).map(field => [field, integer(row[field])])) }))]))
|
||||
equal(counts(expected), counts(after.members[index].stock), 'Only selected customer position and balance lose one box')
|
||||
const old = before.members[index].stock.ledger, newRows = after.members[index].stock.ledger.filter(row => !old.some(prior => id(prior.id) === id(row.id)))
|
||||
equal(old, after.members[index].stock.ledger.filter(row => old.some(prior => id(prior.id) === id(row.id))), 'Immutable old movements preserved')
|
||||
check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity', newRows.length === 3 && ['ALLOCATE','PICK','SHIP'].every(type => newRows.filter(row =>
|
||||
row.businessType === type && id(row.positionId) === member.positionId && row.businessOrderNo === fixture.dispatchNo && Number(row.deltaPackages) === (type === 'SHIP' ? -1 : 1)).length === 1))
|
||||
await request('POST', pickPath, body, 'Replay exact customer pick', true)
|
||||
equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record')
|
||||
}
|
||||
try {
|
||||
let initial=await snapshot('INITIAL')
|
||||
check('Minimal fresh fixture has one available box per customer and no prior execution',initial.trip.trip.status==='PLANNED'
|
||||
&& initial.members.every((row,index)=>row.dispatch.status==='PENDING_DEPARTURE' && integer(row.dispatch.packageCount)===1
|
||||
&& integer(row.dispatch.pickedPackageCount)===0 && row.outbound.outbound.status==='WAIT_OUTBOUND' && row.delivery.order.status==='WAIT_DELIVERY'
|
||||
&& integer(row.stock.positions.find(p=>id(p.id)===id(fixture.members[index].positionId)).availablePackages)===1
|
||||
&& row.stock.balances.every(balance=>balanceBuckets.filter(field=>field!=='availablePackages').every(field=>integer(balance[field])===0))
|
||||
&& row.stock.balances.reduce((sum,balance)=>sum+integer(balance.availablePackages),0)===1))
|
||||
let detail=initial.trip
|
||||
for(const member of fixture.members)detail=await request('POST',`${tripPath}/dispatches/${encode(member.dispatchNo)}/review`,
|
||||
{recordVersion:detail.trip.recordVersion,decision:'APPROVED'},'Review each customer dispatch separately')
|
||||
check('Both customer dispatches approved',detail.dispatches.every(row=>row.review?.status==='APPROVED'))
|
||||
await pick(fixture.members[0],0)
|
||||
const rejectedBefore=await snapshot('GATE_BEFORE')
|
||||
check('Precisely one customer is ready',rejectedBefore.members[0].dispatch.status==='READY_TO_DEPART' && integer(rejectedBefore.members[1].dispatch.pickedPackageCount)===0)
|
||||
await request('POST',`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`,
|
||||
{recordVersion:rejectedBefore.trip.trip.recordVersion,idempotencyKey:key('UNREADY-DEPART'),remark:context.marker},'Unready second customer blocks whole trip before execution',true,
|
||||
message=>message.includes(fixture.members[1].dispatchNo) && message.includes('请完成拣货并核平全部箱数'))
|
||||
equal(rejectedBefore,await snapshot('GATE_AFTER'),'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
|
||||
proof.gateProof='PREREQUISITE_REJECTION_VERIFIED'
|
||||
await pick(fixture.members[1],1)
|
||||
const loading=await request('GET',`${tripPath}/loading`,undefined,'Read ready shared loading')
|
||||
check('Both real pallets ready for loading',!loading.blockReason && loading.cargo?.length===2)
|
||||
detail=await request('GET',tripPath,undefined,'Read current trip route')
|
||||
const measured=controlledPalletMeasurements(loading.cargo,detail.trip.stops)
|
||||
const loaded=await request('POST',`${tripPath}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:measured,reason:context.marker},'Confirm controlled TEST measurements')
|
||||
const reviewed=await request('POST',`${tripPath}/loading-review`,{recordVersion:loaded.recordVersion,decision:'APPROVED'},'Approve whole-trip loading')
|
||||
const ready=await snapshot('READY')
|
||||
const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker}
|
||||
const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/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'
|
||||
let cargoBasis, originalLoadingHash
|
||||
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
|
||||
} else {
|
||||
const initial = await snapshot('INITIAL')
|
||||
check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED'
|
||||
&& initial.dispatch.status === 'ACTIVE' && initial.dispatch.stage === 'LOADING' && 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) === 0
|
||||
&& initial.dispatch.documents?.deliveries?.length === 0 && initial.members.every((row,index) =>
|
||||
integer(row.stock.positions.find(p => id(p.id) === fixture.members[index].positionId).availablePackages) === 1
|
||||
&& row.stock.balances.every(balance => balanceBuckets.filter(field => field !== 'availablePackages').every(field => integer(balance[field]) === 0))
|
||||
&& row.stock.balances.reduce((sum,balance) => sum + integer(balance.availablePackages), 0) === 1))
|
||||
await pick(proof.members[0], 0)
|
||||
const rejectedBefore = await snapshot('GATE_BEFORE')
|
||||
check('Precisely one customer picked while main remains LOADING', integer(rejectedBefore.dispatch.counts.pickedPackageCount) === 1
|
||||
&& rejectedBefore.dispatch.stage === 'LOADING' && rejectedBefore.task.pickingReconciled === false)
|
||||
const preview = await request('GET', `${pdaPath}/loading`, undefined, 'Read incomplete native loading manifest', true)
|
||||
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
|
||||
}
|
||||
const departed=await request('POST',departPath,departBody,'Depart whole trip once',true)
|
||||
check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2
|
||||
&& canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort()))
|
||||
const after=await snapshot('DEPARTED')
|
||||
check('Both outbounds completed and trip in transit',after.trip.trip.status==='IN_TRANSIT' && after.members.every(row=>row.dispatch.status==='DEPARTED' && row.outbound.outbound.status==='OUTBOUNDED'))
|
||||
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,'Replay exact shared departure',true)
|
||||
check('Shared departure replay is explicit',replay.replayed===true)
|
||||
equal(after,await snapshot('DEPART_REPLAY'),'Departure replay preserves both complete inventories and execution facts')
|
||||
for(const [index,member] of fixture.members.entries()) {
|
||||
const before=await snapshot(`BEFORE_SIGN_${index}`),path=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId)
|
||||
await request('PUT',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/status`,member.customerId),
|
||||
{status:'DELIVERING',expectedVersion:before.members[index].delivery.order.version,remark:context.marker},'Start only this customer delivery')
|
||||
const documentPath=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`,member.customerId)
|
||||
const docs=await request('GET',documentPath,undefined,'Read this delivery document version')
|
||||
const uploaded=await context.requestMultipart('POST',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),
|
||||
{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${member.deliveryNo}`)})
|
||||
proof.requests.push({method:'POST',path:scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),label:'Upload own synthetic receipt',http:uploaded?.http,code:uploaded?.body?.code,requestId:uploaded?.requestId,traceId:uploaded?.traceId})
|
||||
check('Receipt uploaded through correct delivery endpoint',ok(uploaded) && id(uploaded.body?.data?.fileId))
|
||||
const documents=(docs.categories||[]).filter(row=>row.category!=='RECEIPT').map(row=>({category:row.category,fileIds:(row.files||[]).map(file=>file.fileId)}))
|
||||
documents.push({category:'RECEIPT',fileIds:[id(uploaded.body.data.fileId)]})
|
||||
await request('PUT',documentPath,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents},'Attach receipt to independently complete this delivery')
|
||||
const signed=await snapshot(`SIGNED_${index}`)
|
||||
check('This customer independently signed',signed.members[index].delivery.order.status==='DELIVERED')
|
||||
equal(before.members[1-index].delivery,signed.members[1-index].delivery,'Other customer delivery is not signed or changed implicitly')
|
||||
equal(before.members.map(row=>row.stock),signed.members.map(row=>row.stock),'Signing does not mutate either customer inventory')
|
||||
// Retain the independently readable result as evidence, not a fabricated completion command.
|
||||
await request('GET',path,undefined,'Read signed customer delivery')
|
||||
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)
|
||||
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))
|
||||
Object.assign(member, { outboundNo: deliveries[0].outboundNo, deliveryNo: deliveries[0].deliveryNo })
|
||||
Object.assign(fixture.members[index], { outboundNo: member.outboundNo, deliveryNo: member.deliveryNo })
|
||||
}
|
||||
proof.status='VERIFIED';return proof
|
||||
} catch(error) {proof.status='FAILED';proof.error=error.message;throw error}
|
||||
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)
|
||||
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)
|
||||
&& 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')
|
||||
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')
|
||||
const uploadPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`, member.customerId)
|
||||
const uploadEntry = { method: 'POST', path: uploadPath, label: 'Upload own synthetic receipt', status: 'SENDING' }; proof.requests.push(uploadEntry); await context.checkpoint?.()
|
||||
let uploaded
|
||||
try { uploaded = await context.requestMultipart('POST', uploadPath, { name: `${context.marker}-${index}-TEST-receipt.txt`, type: 'text/plain',
|
||||
content: new TextEncoder().encode(`${context.marker} synthetic TEST receipt ${member.deliveryNo}`) }) }
|
||||
catch (error) { uploadEntry.status = 'UNKNOWN_RESULT'; await context.checkpoint?.(); throw error }
|
||||
Object.assign(uploadEntry, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code, requestId: uploaded?.requestId, traceId: uploaded?.traceId }); await context.checkpoint?.()
|
||||
check('Receipt uploaded through correct customer endpoint', ok(uploaded) && uploaded.requestId && uploaded.traceId && id(uploaded.body?.data?.fileId))
|
||||
const documents = (docs.categories || []).filter(row => row.category !== 'RECEIPT').map(row => ({ category: row.category, fileIds: (row.files || []).map(file => file.fileId) }))
|
||||
documents.push({ category: 'RECEIPT', fileIds: [id(uploaded.body.data.fileId)] })
|
||||
await request('PUT', documentPath, { expectedVersion: docs.version, idempotencyKey: key(`SIGN-${index}`), bolNo: docs.bolNo || null, documents }, 'Attach receipt to independently complete this delivery')
|
||||
const signed = await snapshot(`SIGNED_${index}`)
|
||||
check('This customer independently signed', signed.members[index].delivery.order.status === 'DELIVERED')
|
||||
equal(before.members[1-index].delivery, signed.members[1-index].delivery, 'Other customer delivery is not signed or changed implicitly')
|
||||
equal(before.members.map(row => row.stock), signed.members.map(row => row.stock), 'Signing does not mutate either customer inventory')
|
||||
}
|
||||
const started = Date.now()
|
||||
proof.completionPolling = { ...polling, status: 'WAITING', attempts: [] }
|
||||
for (let attempt = 1; attempt <= polling.maxAttempts; attempt++) {
|
||||
if (Date.now() - started >= polling.deadlineMs) break
|
||||
const entry = { attempt, elapsedMs: Date.now() - started, status: 'READING' }
|
||||
proof.completionPolling.attempts.push(entry)
|
||||
const current = await request('GET', path, undefined, 'Read bounded automatic delivery completion progress')
|
||||
check('Completion progress retains the exact normal main, counts and both execution-document owners', current.dispatchNo === fixture.dispatchNo
|
||||
&& current.status === 'ACTIVE' && ['DELIVERING','DELIVERED'].includes(current.stage)
|
||||
&& /^(0|[1-9]\d*)$/.test(id(current.recordVersion)) && integer(current.counts?.packageCount) === 2
|
||||
&& integer(current.counts?.pickedPackageCount) === 2 && integer(current.counts?.shippedPackageCount) === 2
|
||||
&& current.documents?.outbounds?.length === 2 && current.documents?.deliveries?.length === 2
|
||||
&& proof.members.every(member => current.documents.outbounds.some(row => id(row.customerId) === member.customerId && row.outboundNo === member.outboundNo)
|
||||
&& current.documents.deliveries.some(row => id(row.customerId) === member.customerId && row.deliveryNo === member.deliveryNo && row.outboundNo === member.outboundNo)))
|
||||
Object.assign(entry, { status: 'RESPONDED', stage: current.stage, recordVersion: current.recordVersion, elapsedMs: Date.now() - started })
|
||||
if (entry.elapsedMs >= polling.deadlineMs) break
|
||||
if (current.stage === 'DELIVERED') { proof.completionPolling.status = 'COMPLETED'; break }
|
||||
await context.checkpoint?.()
|
||||
const remaining = polling.deadlineMs - (Date.now() - started)
|
||||
if (attempt < polling.maxAttempts && remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(polling.intervalMs, remaining)))
|
||||
}
|
||||
proof.completionPolling.elapsedMs = Date.now() - started
|
||||
if (proof.completionPolling.status !== 'COMPLETED') {
|
||||
proof.completionPolling.status = 'TIMED_OUT'
|
||||
await context.checkpoint?.()
|
||||
throw new Error('Automatic delivery completion did not reach DELIVERED within the explicit read-only polling bounds')
|
||||
}
|
||||
const final = await snapshot('FINAL')
|
||||
check('All normal customer signatures aggregate native delivery completion', final.dispatch.status === 'ACTIVE' && final.dispatch.stage === 'DELIVERED')
|
||||
proof.status = 'VERIFIED'; return proof
|
||||
} catch (error) {
|
||||
proof.status = 'FAILED'; proof.error = error.message
|
||||
if (proof.completionPolling?.status === 'WAITING') {
|
||||
proof.completionPolling.status = 'FAILED'; proof.completionPolling.error = error.message
|
||||
const attempt = proof.completionPolling.attempts.at(-1)
|
||||
if (attempt?.status === 'READING') attempt.status = 'FAILED'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createFleetCrossCustomerFixture, preparationContract } from './fleet-cross-customer-fixture.mjs'
|
||||
import { createFleetCrossCustomerFixture, preparationContract, safeFixtureCount } from './fleet-cross-customer-fixture.mjs'
|
||||
import { fixtureContract } from './fleet-cross-customer-acceptance.mjs'
|
||||
|
||||
// Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too.
|
||||
function model(fault = '') {
|
||||
function model(fault = '', countPatch = {}) {
|
||||
const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = []
|
||||
const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' }
|
||||
const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30,
|
||||
@@ -23,11 +23,10 @@ function model(fault = '') {
|
||||
ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo,
|
||||
beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] }
|
||||
}
|
||||
const cargo = () => trip.dispatches.map(binding => {
|
||||
const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d)
|
||||
return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`,
|
||||
destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 }
|
||||
})
|
||||
let native
|
||||
const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId,
|
||||
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0,
|
||||
destination: { key: 'FBA:FBA' } }))
|
||||
const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' }
|
||||
const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture },
|
||||
createdResources: { merchantQualificationFixture: { marker: 'PARENT' } },
|
||||
@@ -58,7 +57,7 @@ function model(fault = '') {
|
||||
customers.push(customer)
|
||||
return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password })
|
||||
}
|
||||
if (path.endsWith('/submit-review')) return response({ status: 'PENDING' })
|
||||
if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' })
|
||||
if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id })))
|
||||
if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' })
|
||||
if (path.endsWith('/approve')) return response({})
|
||||
@@ -77,10 +76,14 @@ function model(fault = '') {
|
||||
if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id })
|
||||
if (path === '/api/merchant/v1/last-leg/warehouse-transfers') {
|
||||
assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, [])
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1)
|
||||
assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1); assert.equal(body.items[0].quantity, 1)
|
||||
const transferNo = `IN${transfers.length}`
|
||||
const itemId = `FORECAST-ITEM${transfers.length}`
|
||||
const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId,
|
||||
status: 'DRAFT', pallets: [] }
|
||||
status: 'DRAFT', packageCount: 1, pallets: [],
|
||||
items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }],
|
||||
cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST',
|
||||
transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] }
|
||||
transfers.push(transfer); return response(transfer)
|
||||
}
|
||||
if (path.includes('/warehouse-transfers/')) {
|
||||
@@ -89,10 +92,39 @@ function model(fault = '') {
|
||||
if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id)
|
||||
if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW'
|
||||
else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' }
|
||||
else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }]
|
||||
else if (path.endsWith('/pallets/drafts')) {
|
||||
// The actual PalletDraft DTO accepts identity only; never derive measurement from ignored request fields.
|
||||
assert.deepEqual(Object.keys(body), ['idempotencyKey'])
|
||||
transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: 0, lengthCm: 0, widthCm: 0, heightCm: 0, volumeCbm: 0, grossWeightKg: 0 }]
|
||||
}
|
||||
else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` })
|
||||
else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' }
|
||||
else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' }
|
||||
else if (path.endsWith('/cartons/count')) {
|
||||
assert.equal(method, 'POST'); assert.equal(transfer.pallets[0].status, 'BOUND')
|
||||
assert.equal(transfer.pallets[0].cartonCount, 0); assert.equal(body.forecastPackageCount, transfer.packageCount)
|
||||
assert.equal(body.boxes.length, 1)
|
||||
const box = body.boxes[0], item = transfer.items[0], carton = transfer.cartons[0], pallet = transfer.pallets[0]
|
||||
assert.equal(box.cartonMark, carton.cartonMark); assert.equal(box.transferItemId, item.id)
|
||||
assert.equal(box.transferPalletId, pallet.id); assert.notEqual(box.transferPalletId, pallet.warehousePalletId)
|
||||
assert.deepEqual([box.length, box.width, box.height, box.weight], [100, 100, 100, 100])
|
||||
assert.deepEqual(box.items, [{ skuCode: item.skuCode, quantity: item.quantity }])
|
||||
Object.assign(carton, { transferPalletId: pallet.id, status: 'COUNTED', countedAt: '2026-10-20T09:00:00',
|
||||
length: box.length, width: box.width, height: box.height, weight: box.weight, quantity: 1,
|
||||
items: [{ transferItemId: item.id, skuCode: item.skuCode, quantity: 1 }] })
|
||||
Object.assign(pallet, { cartonCount: 1, volumeCbm: 1, grossWeightKg: 100 })
|
||||
if (fault === 'uncounted carton') { carton.status = 'FORECAST'; carton.countedAt = null }
|
||||
if (fault === 'wrong counted pallet') carton.transferPalletId = 'OTHER-PALLET'
|
||||
if (fault === 'wrong counted sku') carton.items[0].skuCode = 'OTHER-SKU'
|
||||
if (fault === 'missing carton measurement') carton.weight = null
|
||||
if (fault === 'zero counted pallet') pallet.cartonCount = 0
|
||||
if (fault === 'wrong measured pallet weight') pallet.grossWeightKg = 0
|
||||
}
|
||||
else if (path.endsWith('/palletize')) {
|
||||
assert.equal(transfer.cartons.length, 1); assert.equal(transfer.cartons[0].status, 'COUNTED'); assert.ok(transfer.cartons[0].countedAt)
|
||||
assert.equal(transfer.pallets[0].cartonCount, 1); assert.equal(transfer.pallets[0].grossWeightKg, 100)
|
||||
assert.equal(transfer.pallets[0].volumeCbm, 1); assert.ok(body.photoFileIds.length)
|
||||
transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED'
|
||||
}
|
||||
else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH'
|
||||
else assert.equal(method, 'GET')
|
||||
return response(transfer)
|
||||
@@ -108,43 +140,44 @@ function model(fault = '') {
|
||||
if (path.endsWith('/dispatches/candidates')) {
|
||||
const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n]
|
||||
assert.equal(transfer.status, 'READY_FOR_DISPATCH')
|
||||
return response([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo,
|
||||
warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }])
|
||||
return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId,
|
||||
warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' },
|
||||
sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] })
|
||||
}
|
||||
if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` })
|
||||
if (path.endsWith('/draft-locks/lock')) return response(null)
|
||||
if (path.endsWith('/pre-arrange')) {
|
||||
const n = Number(body.planPalletIds[0].slice(-1))
|
||||
const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }],
|
||||
packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 }
|
||||
dispatches.push(dispatch); return response(dispatch)
|
||||
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
|
||||
assert.equal(body.cargo.length, 2); assert.equal(body.warehouseId, '10')
|
||||
assert.deepEqual(body.cargo.map(row => row.planPalletId), ['PLAN0', 'PLAN1'])
|
||||
assert.ok(body.cargo.every(row => !Object.hasOwn(row, 'customerId')))
|
||||
native = { dispatchNo: 'DS-ONE', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: 0,
|
||||
counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {},
|
||||
documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} }
|
||||
trip.dispatches = [{ dispatchNo: native.dispatchNo }]
|
||||
Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] })
|
||||
dispatches.push(native); return response(native)
|
||||
}
|
||||
if (path.endsWith('/master-data/vehicles')) return response({ id: 'VEHICLE' })
|
||||
if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) }
|
||||
if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' })
|
||||
if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' })
|
||||
if (path.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) }
|
||||
if (path.includes('/fleet/trips/TRIP')) {
|
||||
if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() })
|
||||
if (method !== 'GET') {
|
||||
assert.equal(body.recordVersion, trip.trip.recordVersion)
|
||||
trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1)
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 })
|
||||
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion,
|
||||
items: fault === 'missing second cargo' ? cargo().slice(0,1) : cargo() })
|
||||
if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) {
|
||||
if (method === 'PUT') {
|
||||
assert.equal(body.recordVersion, native.recordVersion)
|
||||
Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId })
|
||||
native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } }
|
||||
native.recordVersion++
|
||||
} else if (method === 'POST') {
|
||||
assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++
|
||||
if (path.endsWith('/submit-review')) native.reviewState = 'DISPATCH_PENDING'
|
||||
if (path.endsWith('/dispatch-review')) { native.reviewState = 'LOADING_PENDING'; native.reviews.dispatchReview = { status: 'APPROVED' } }
|
||||
if (path.endsWith('/loading-review')) { native.reviewState = 'APPROVED'; native.reviews.loadingReview = { status: 'APPROVED' }; native.stage = 'LOADING' }
|
||||
}
|
||||
if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId })
|
||||
else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } })
|
||||
else if (path.endsWith('/plan')) trip.trip.stops = body.stops
|
||||
else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED'
|
||||
return response(trip)
|
||||
const detail = copy(native)
|
||||
if (fault === 'foreign delivery') detail.documents.deliveries = [{ customerId: 'OTHER' }]
|
||||
return response(detail)
|
||||
}
|
||||
const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n]
|
||||
const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' }
|
||||
if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] })
|
||||
if (path.endsWith('/transfer-delivery')) {
|
||||
assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30)
|
||||
return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 })
|
||||
}
|
||||
if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) }
|
||||
if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] })
|
||||
if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch)
|
||||
throw new Error(`Unexpected offline request ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
@@ -180,15 +213,28 @@ test('real helpers build independent current-run customers, receipts and one com
|
||||
assert.notEqual(m.customers[0].marker, m.customers[1].marker)
|
||||
const proof = m.context.createdResources.fleetCrossCustomerFixture
|
||||
assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT'])
|
||||
assert.equal(m.trip.dispatches.length, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2)
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2)
|
||||
assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA'])
|
||||
assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1)
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/cartons/count')).length, 2)
|
||||
for (const member of fixture.members) {
|
||||
const prefix = `/api/pda/v1/last-leg/warehouse-transfers/${member.incomingNo}`
|
||||
const draft = m.calls.findIndex(row => row.raw === `${prefix}/pallets/drafts`)
|
||||
const bind = m.calls.findIndex(row => row.raw.startsWith(`${prefix}/pallets/`) && row.raw.endsWith('/bind'))
|
||||
const count = m.calls.findIndex(row => row.raw === `${prefix}/cartons/count`)
|
||||
const photo = m.calls.findIndex(row => row.raw === `${prefix}/measurement-photos/files`)
|
||||
const receipt = m.calls.findIndex(row => row.raw === `${prefix}/palletize`)
|
||||
assert.ok(draft < bind && bind < count && count < photo && photo < receipt)
|
||||
assert.equal(member.measurementReceipt.countedCarton.cartonMark, m.calls[count].body.boxes[0].cartonMark)
|
||||
assert.equal(member.measurementReceipt.syntheticTestMaterials, true)
|
||||
assert.equal(member.measurementReceipt.finalizedPallet.cartonCount, 1)
|
||||
}
|
||||
assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0)
|
||||
assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw)))
|
||||
const persisted = JSON.stringify(m.context.createdResources)
|
||||
for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret))
|
||||
})
|
||||
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) {
|
||||
test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => {
|
||||
const m = model(fault)
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options))
|
||||
@@ -198,6 +244,8 @@ for (const fault of ['disabled receiving', 'reused customer', 'foreign login', '
|
||||
assert.equal(logouts.length, logins.length)
|
||||
assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/')))
|
||||
if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0)
|
||||
if (['missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight'].includes(fault))
|
||||
assert.equal(m.calls.some(row => row.raw.endsWith('/palletize') || row.raw === '/api/platform/v1/last-leg/dispatches'), false)
|
||||
const previousCount = m.calls.length
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/)
|
||||
assert.equal(m.calls.length, previousCount)
|
||||
@@ -211,3 +259,31 @@ test('missing explicit warehouse or caller PDA fails before reading or writing A
|
||||
assert.equal(m.calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
test('native Jackson Long count strings build the same complete one-main fixture', async () => {
|
||||
const m = model('', { packageCount: '2', pickedPackageCount: '0', shippedPackageCount: '0' })
|
||||
const fixture = await createFleetCrossCustomerFixture(m.context, m.options)
|
||||
assert.equal(fixture.dispatchNo, 'DS-ONE')
|
||||
assert.equal(fixture.tripNo, 'TRIP')
|
||||
assert.equal(fixture.members.length, 2)
|
||||
assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo))
|
||||
})
|
||||
|
||||
test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => {
|
||||
for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) {
|
||||
assert.equal(safeFixtureCount(value), expected)
|
||||
}
|
||||
for (const value of [null, undefined, true, false, '', ' ', ' 2 ', '02', '+2', '-0', '-1', -1, 1.5, '1.5', '1e2', '2E0', NaN, Infinity, {}, [], Number.MAX_SAFE_INTEGER + 1, '9007199254740992']) {
|
||||
assert.throws(() => safeFixtureCount(value), /非负安全整数/)
|
||||
}
|
||||
})
|
||||
|
||||
test('invalid created main counts fail before resource preparation or picking', async () => {
|
||||
for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) {
|
||||
const m = model('', { [field]: field === 'packageCount' ? '2e0' : false })
|
||||
await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /非负安全整数/)
|
||||
assert.equal(m.calls.some(row => row.raw.endsWith('/master-data/vehicles')), false)
|
||||
assert.equal(m.calls.some(row => row.raw.endsWith('/pick')), false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,11 @@ const ROOT = '/api/platform/v1/last-leg'
|
||||
const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers'
|
||||
const OPTIONS = { scenario: MODULE, deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
export function safeFixtureCount(value) {
|
||||
if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9]\d*)$/.test(String(value))
|
||||
|| !Number.isSafeInteger(Number(value))) throw new Error('原生夹具数量须为非负安全整数')
|
||||
return Number(value)
|
||||
}
|
||||
const encode = encodeURIComponent
|
||||
const ok = response => response?.http === 200 && id(response.body?.code) === '200'
|
||||
const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
|
||||
@@ -22,7 +27,7 @@ const localTime = delay => {
|
||||
export const preparationContract = Object.freeze({
|
||||
stage: fixtureContract.stage,
|
||||
inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope',
|
||||
writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; two dispatches, one shared trip with a test vehicle/qualified driver, two outbounds and deliveries. No picking.',
|
||||
writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and a TEST vehicle/qualified driver; both reviews approved. No picking or delivery arrangement yet.',
|
||||
lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.',
|
||||
})
|
||||
|
||||
@@ -59,8 +64,8 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
inboundCode: member.incomingNo, page: '1', size: '200' })
|
||||
const grouped = await request('GET', `${ROOT}/inventory/balances?${query}`, undefined, 'Read exact fresh receipt balance page')
|
||||
const ledger = await request('GET', `${ROOT}/inventory/ledger?${query}&businessOrderNo=${encode(member.incomingNo)}`, undefined, 'Read exact receipt movement page')
|
||||
check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && Number(grouped.total) === 1
|
||||
&& ledger?.records?.length === 1 && Number(ledger.total) === 1 && id(grouped.records[0].id))
|
||||
check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && safeFixtureCount(grouped.total) === 1
|
||||
&& ledger?.records?.length === 1 && safeFixtureCount(ledger.total) === 1 && id(grouped.records[0].id))
|
||||
const path = `${ROOT}/inventory/balances/${encode(grouped.records[0].id)}`
|
||||
const balance = await request('GET', `${path}?${own(member)}`, undefined, 'Read original raw balance identity')
|
||||
const positions = await request('GET', `${path}/positions?${own(member)}`, undefined, 'Read original raw physical position')
|
||||
@@ -146,7 +151,7 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName,
|
||||
deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000),
|
||||
needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`),
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1 }] }
|
||||
items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] }
|
||||
let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken)
|
||||
member.incomingNo = incoming?.transferNo
|
||||
check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo
|
||||
@@ -162,8 +167,14 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo
|
||||
&& task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId)
|
||||
&& task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0)
|
||||
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`),
|
||||
cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, 'Measure one real test pallet', pdaToken)
|
||||
const forecastItem = task.items?.[0], forecastCarton = task.cartons?.[0]
|
||||
check('One original forecast carton and declared SKU belong to this fresh one-box incoming', safeFixtureCount(task.packageCount) === 1
|
||||
&& task.items?.length === 1 && forecastItem?.id && forecastItem.skuCode === createBody.items[0].skuCode
|
||||
&& safeFixtureCount(forecastItem.packageCount) === 1 && safeFixtureCount(forecastItem.quantity) === 1
|
||||
&& task.cartons?.length === 1 && forecastCarton?.cartonMark
|
||||
&& (forecastCarton.transferItemId == null || id(forecastCarton.transferItemId) === id(forecastItem.id)))
|
||||
// PalletDraft creates only an empty identity; measurements belong to the real carton-count command below.
|
||||
const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`) }, 'Create empty TEST pallet draft', pdaToken)
|
||||
check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT')
|
||||
const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}`
|
||||
const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken)
|
||||
@@ -171,12 +182,32 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo)
|
||||
member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo
|
||||
const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken)
|
||||
check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && Number(bound.pallets[0].cartonCount) === 1)
|
||||
check('Bound pallet is exactly the generated empty physical pallet before carton counting', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND'
|
||||
&& id(bound.pallets[0].id) === id(measured.pallets[0].id)
|
||||
&& id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 0)
|
||||
const counted = await request('POST', `${pdaPath}/cartons/count`, { idempotencyKey: key(`COUNT-${suffix}`),
|
||||
forecastPackageCount: safeFixtureCount(task.packageCount), boxes: [{ cartonMark: forecastCarton.cartonMark,
|
||||
transferItemId: id(forecastItem.id), transferPalletId: id(measured.pallets[0].id),
|
||||
length: 100, width: 100, height: 100, weight: 100,
|
||||
items: [{ skuCode: forecastItem.skuCode, quantity: safeFixtureCount(forecastItem.quantity) }] }] },
|
||||
'Count original TEST carton, record dimensions/SKU and allocate it to the bound pallet', pdaToken)
|
||||
const countedCarton = counted?.cartons?.[0], countedPallet = counted?.pallets?.[0]
|
||||
check('Actual carton count proves one declared box/SKU on the original bound pallet', counted?.transferNo === member.incomingNo
|
||||
&& counted.cartons?.length === 1 && countedCarton.cartonMark === forecastCarton.cartonMark
|
||||
&& countedCarton.status === 'COUNTED' && countedCarton.countedAt
|
||||
&& id(countedCarton.transferPalletId) === id(measured.pallets[0].id)
|
||||
&& id(countedCarton.transferItemId) === id(forecastItem.id)
|
||||
&& ['length', 'width', 'height', 'weight'].every(field => Number(countedCarton[field]) === 100)
|
||||
&& safeFixtureCount(countedCarton.quantity) === 1 && countedCarton.items?.length === 1
|
||||
&& countedCarton.items[0].skuCode === forecastItem.skuCode
|
||||
&& id(countedCarton.items[0].transferItemId) === id(forecastItem.id) && safeFixtureCount(countedCarton.items[0].quantity) === 1
|
||||
&& counted.pallets?.length === 1 && countedPallet.status === 'BOUND' && id(countedPallet.id) === id(measured.pallets[0].id)
|
||||
&& id(countedPallet.warehousePalletId) === member.palletId && safeFixtureCount(countedPallet.cartonCount) === 1
|
||||
&& Number(countedPallet.grossWeightKg) === 100 && Number(countedPallet.volumeCbm) === 1)
|
||||
const photoPath = `${pdaPath}/measurement-photos/files`
|
||||
const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath,
|
||||
{ name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence')
|
||||
check('Measurement evidence is a stored file', photo?.fileId && Number(photo.sizeBytes) > 0)
|
||||
check('Measurement evidence is a stored file', photo?.fileId && safeFixtureCount(photo.sizeBytes) > 0)
|
||||
incoming = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${suffix}`), photoFileIds: [id(photo.fileId)] }, 'Complete real PDA receipt', pdaToken)
|
||||
check('Real PDA receipt completed with original physical pallet', incoming?.status === 'WAIT_CUSTOMER_CONFIRMATION'
|
||||
&& incoming.pallets?.length === 1 && incoming.pallets[0].status === 'COMPLETED' && id(incoming.pallets[0].warehousePalletId) === member.palletId)
|
||||
@@ -184,73 +215,51 @@ export async function createFleetCrossCustomerFixture(context, { execute = false
|
||||
check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH')
|
||||
member.receipt = await stock(member)
|
||||
member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true
|
||||
const dispatchBase = `${ROOT}/dispatches`
|
||||
const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: member.incomingNo })}`, undefined, 'Read own real received dispatch candidate')
|
||||
check('Candidate has exact customer, incoming, warehouse and physical pallet ownership', candidates?.length === 1
|
||||
&& id(candidates[0].customerId) === member.customerId && id(candidates[0].overseasWarehouseId) === id(warehouseId)
|
||||
&& candidates[0].inboundCode === member.incomingNo && id(candidates[0].warehousePalletId) === member.palletId
|
||||
&& candidates[0].palletNo === member.palletNo && candidates[0].sourceStage === 'RECEIVED' && Number(candidates[0].packageCount) === 1 && candidates[0].planPalletId)
|
||||
member.draftToken = key(`DRAFT-${suffix}`)
|
||||
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: member.draftToken, capacity: 30 }, 'Create isolated dispatch draft')
|
||||
check('Dispatch draft has explicit container identity', container?.id)
|
||||
member.draftContainerId = id(container.id)
|
||||
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken: member.draftToken,
|
||||
draftContainerId: member.draftContainerId }, 'Lock this customer received pallet')
|
||||
const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken: member.draftToken }, 'Prearrange one independent customer dispatch')
|
||||
check('Dispatch contains exactly this unpicked pallet', dispatch?.dispatchNo && dispatch.status === 'PENDING_DEPARTURE'
|
||||
&& dispatch.items?.length === 1 && id(dispatch.items[0].warehousePalletId) === member.palletId
|
||||
&& Number(dispatch.packageCount) === 1 && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
|
||||
member.dispatchNo = dispatch.dispatchNo
|
||||
member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true, countedCarton,
|
||||
sourceType: 'WAREHOUSE_TRANSFER_PDA', finalizedPallet: incoming.pallets?.find(row => id(row.warehousePalletId) === member.palletId) }
|
||||
const candidates = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`,
|
||||
undefined, 'Read own native received source selection')
|
||||
check('Candidate list is complete and has exact customer, incoming, warehouse and physical pallet ownership', candidates?.complete === true
|
||||
&& candidates.candidates?.length === 1 && id(candidates.candidates[0].customerId) === member.customerId
|
||||
&& id(candidates.candidates[0].warehouseId) === id(warehouseId) && candidates.candidates[0].inboundCode === member.incomingNo
|
||||
&& candidates.candidates[0].palletNo === member.palletNo && safeFixtureCount(candidates.candidates[0].availablePackages) === 1
|
||||
&& candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN' && candidates.candidates[0].selection.planPalletId
|
||||
&& candidates.candidates[0].sourceVersion && candidates.candidates[0].selection.sourceVersion === candidates.candidates[0].sourceVersion
|
||||
&& candidates.candidates[0].blockReasons?.length === 0)
|
||||
member.selection = candidates.candidates[0].selection
|
||||
member.destination = candidates.candidates[0].destination
|
||||
}
|
||||
for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId', 'dispatchNo'])
|
||||
for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId'])
|
||||
check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, proof.members[0].dispatchNo, id(warehouseId))
|
||||
proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId),
|
||||
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null,
|
||||
remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] }
|
||||
const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers')
|
||||
check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE'
|
||||
&& created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0)
|
||||
proof.dispatchNo = created.dispatchNo
|
||||
for (const member of proof.members) member.dispatchNo = proof.dispatchNo
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, proof.dispatchNo, id(warehouseId))
|
||||
proof.tripNo = tripProof.tripNo
|
||||
const tripPath = `${ROOT}/fleet/trips/${encode(proof.tripNo)}`
|
||||
let detail = await request('GET', tripPath, undefined, 'Read first member trip version')
|
||||
detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion,
|
||||
dispatchNo: proof.members[1].dispatchNo, reason: context.marker }, 'Bind second independent customer dispatch')
|
||||
const loading = await request('GET', `${tripPath}/loading`, undefined, 'Read complete two-customer cargo destinations')
|
||||
check('Shared trip cargo consists of exactly both owned physical pallets', loading?.cargo?.length === 2 && proof.members.every(member =>
|
||||
loading.cargo.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId
|
||||
&& id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destinationKey && Number(row.packages) === 1)))
|
||||
const destinations = new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }]))
|
||||
detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion,
|
||||
plannedDepartureAt: detail.trip.plannedDepartureAt, expectedDriverEndAt: detail.trip.expectedDriverEndAt,
|
||||
stops: [{ name: origin.warehouse.warehouseName }, ...destinations.values()], reason: context.marker }, 'Replace route with complete actual destinations')
|
||||
tripProof.stops = detail.trip.stops
|
||||
check('Route includes every actual destination', [...destinations.keys()].every(destinationKey => detail.trip.stops?.slice(1).some(row => row.destinationKey === destinationKey)))
|
||||
for (const [index, member] of proof.members.entries()) {
|
||||
const path = `${ROOT}/dispatches/${encode(member.dispatchNo)}`
|
||||
const freight = await request('GET', `${path}/external-freight`, undefined, 'Verify ordinary incoming has no external quote')
|
||||
check('Ordinary incoming requires its own explicit delivery fee', freight?.requiresDeliveryFee === true && freight.quotes?.length === 0)
|
||||
const transferred = await request('POST', `${path}/transfer-delivery`, { idempotencyKey: key(`DELIVERY-${index}`), plannedDeliveryAt: localTime(21600000),
|
||||
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK',
|
||||
vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate,
|
||||
driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', remark: member.customerMarker,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '受控测试派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, 'Create this customer outbound and delivery')
|
||||
check('One customer dispatch produces one outbound and one delivery', transferred?.dispatchNo === member.dispatchNo && transferred.outboundNo
|
||||
&& transferred.deliveries?.length === 1 && transferred.deliveries[0].deliveryNo && Number(transferred.packageCount) === 1)
|
||||
member.outboundNo = transferred.outboundNo; member.deliveryNo = transferred.deliveries[0].deliveryNo
|
||||
const delivery = await request('GET', `${ROOT}/delivery-orders/${encode(member.deliveryNo)}?${own(member)}`, undefined, 'Verify independently owned delivery')
|
||||
const outbound = await request('GET', `${ROOT}/outbound-orders/${encode(member.outboundNo)}?${own(member)}`, undefined, 'Verify independently owned outbound')
|
||||
const dispatch = await request('GET', path, undefined, 'Verify customer dispatch remains unpicked')
|
||||
check('Customer execution records are distinct and untouched', id(delivery?.order?.customerId) === member.customerId
|
||||
&& delivery.order.deliveryNo === member.deliveryNo && delivery.order.dispatchNo === member.dispatchNo && delivery.order.outboundNo === member.outboundNo
|
||||
&& delivery.order.status === 'WAIT_DELIVERY' && outbound?.outbound?.outboundNo === member.outboundNo && outbound.outbound.dispatchNo === member.dispatchNo
|
||||
&& id(outbound.outbound.overseasWarehouseId) === id(warehouseId) && outbound.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& outbound.deliveries?.length === 1 && id(outbound.deliveries[0].customerId) === member.customerId && outbound.deliveries[0].deliveryNo === member.deliveryNo
|
||||
&& dispatch?.status === 'PENDING_DEPARTURE' && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0)
|
||||
const finalStock = await stock(member)
|
||||
check('Trip preparation preserves original available receipt records', JSON.stringify(finalStock) === JSON.stringify(member.receipt))
|
||||
const dispatchPath = `${ROOT}/dispatches/${encode(proof.dispatchNo)}`
|
||||
const detail = await request('GET', dispatchPath, undefined, 'Read one reviewed native cross-customer dispatch')
|
||||
const items = await request('GET', `${dispatchPath}/items`, undefined, 'Read native per-customer cargo identities')
|
||||
check('Both actual customer pallets share one reviewed native dispatch before any picking', detail.dispatchNo === proof.dispatchNo
|
||||
&& detail.status === 'ACTIVE' && detail.stage === 'LOADING' && detail.reviewState === 'APPROVED'
|
||||
&& safeFixtureCount(detail.counts?.packageCount) === 2 && safeFixtureCount(detail.counts?.pickedPackageCount) === 0 && safeFixtureCount(detail.counts?.shippedPackageCount) === 0
|
||||
&& detail.documents?.outbounds?.length === 0 && detail.documents?.deliveries?.length === 0
|
||||
&& items.dispatchNo === proof.dispatchNo && id(items.recordVersion) === id(detail.recordVersion) && items.items?.length === 2
|
||||
&& proof.members.every(member => items.items.some(row => id(row.customerId) === member.customerId
|
||||
&& id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destination?.key === member.destination?.key
|
||||
&& safeFixtureCount(row.scheduledPackages) === 1 && safeFixtureCount(row.pickedPackages) === 0 && row.dispatchItemId)))
|
||||
for (const member of proof.members) {
|
||||
member.dispatchItemId = id(items.items.find(row => id(row.customerId) === member.customerId).dispatchItemId)
|
||||
check('Trip preparation preserves original available receipt records', JSON.stringify(await stock(member)) === JSON.stringify(member.receipt))
|
||||
}
|
||||
detail = await request('GET', tripPath, undefined, 'Verify final two-customer shared trip membership')
|
||||
check('Final trip has only the two fresh customers and remains planned', detail?.trip?.status === 'PLANNED' && detail.dispatches?.length === 2
|
||||
&& proof.members.every(member => detail.dispatches.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId)))
|
||||
for (const field of ['outboundNo', 'deliveryNo']) check(`Independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2)
|
||||
fixture = { marker: context.marker, stage: fixtureContract.stage, tripNo: proof.tripNo, warehouseId: id(warehouseId), pdaToken,
|
||||
members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified }) =>
|
||||
({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified })) }
|
||||
fixture = { marker: context.marker, stage: fixtureContract.stage, dispatchNo: proof.dispatchNo, tripNo: proof.tripNo,
|
||||
warehouseId: id(warehouseId), pdaToken, tripProof,
|
||||
members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination }) =>
|
||||
({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination })) }
|
||||
} catch (error) { failure = error }
|
||||
// Always attempt every successfully opened session, including partial fixture failures. PDA lifecycle belongs to caller.
|
||||
const cleanupErrors = []
|
||||
|
||||
+188
-82
@@ -1,90 +1,196 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { verifyFleetExternalDispatch, nativeArrangementGroups } from './fleet-external-dispatch-acceptance.mjs'
|
||||
|
||||
function fixture(fault = '') {
|
||||
const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 },
|
||||
remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } }
|
||||
const tripProof = { tripNo: 'TR-CURRENT', warehouseId: '92', dispatchNo: 'DP-OWN',
|
||||
stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], externalReceipt: {
|
||||
status: 'VERIFIED', createRequest: { customerId: '900719925474099301' }, incomingNo: 'WT-CURRENT', bookingNo: 'FB-CURRENT',
|
||||
warehousePalletId: '900719925474099311', palletNo: 'PALLET-EXT', finalSegments: [segment], finalBooking: {
|
||||
cargo: { destinationKey: 'FBA:ONT8' }, receipt: { source: { pallets: [{ weightKg: 100, volumeCbm: 1 }] } },
|
||||
},
|
||||
} }
|
||||
let version = 10, linked = false, replanned = false, transferred = false
|
||||
const calls = [], assertions = []
|
||||
const customerId = tripProof.externalReceipt.createRequest.customerId
|
||||
const item = { warehousePalletId: tripProof.externalReceipt.warehousePalletId, packageCount: 1 }
|
||||
const booking = () => ({ bookingNo: 'FB-CURRENT', status: linked ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', recordVersion: linked ? 4 : 3,
|
||||
dispatchNo: linked ? 'DP-EXT' : null })
|
||||
const binding = () => ({ tripRecordVersion: version, booking: booking(), replayed: false })
|
||||
const dispatches = [{ dispatchNo: 'DP-OWN', review: { status: 'PENDING' } }]
|
||||
const detail = () => ({ trip: { recordVersion: version, maxPalletSpaces: 30, vehiclePlate: 'TEST-TRUCK', driverName: 'TEST-DRIVER', driverPhone: '19900000000' }, dispatches })
|
||||
const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' })
|
||||
const context = { marker: 'OFFLINE', createdResources: { fleetTrips: [tripProof] },
|
||||
assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
if (path.endsWith('/capacity-plan')) {
|
||||
if (method === 'POST') { replanned = true; version++ }
|
||||
return response({ recordVersion: version, manifestHash: 'a'.repeat(64), planValid: !linked || replanned || fault === 'stale-plan',
|
||||
capacityPlan: { pallets: [{ warehousePalletId: 'OWN-PALLET', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] },
|
||||
cargo: [{ dispatchNo: 'DP-OWN', warehousePalletId: 'OWN-PALLET', packages: 1 }, ...(linked ? [{ dispatchNo: 'DP-EXT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, packages: 1 }] : [])],
|
||||
availableSegments: replanned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 27 } }] : [segment] })
|
||||
}
|
||||
if (path.includes('/candidates?')) return response([{ customerId: fault === 'foreign-customer' ? 'wrong' : customerId, overseasWarehouseId: '92',
|
||||
inboundCode: 'WT-CURRENT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, palletNo: 'PALLET-EXT', packageCount: 1,
|
||||
sourceStage: 'RECEIVED', planPalletId: 'PLAN-PALLET' }])
|
||||
if (path.endsWith('/drafts/containers')) return response({ id: 'CONTAINER' })
|
||||
if (path.endsWith('/draft-locks/lock')) return response(null)
|
||||
if (path.endsWith('/pre-arrange')) return response({ dispatchNo: 'DP-EXT', packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0, items: [item] })
|
||||
if (path.endsWith('/bookings')) return response({ tripRecordVersion: version, records: [booking()] })
|
||||
if (path.endsWith('/bookings/FB-CURRENT/dispatch')) {
|
||||
if (linked) return response({ ...binding(), replayed: true })
|
||||
linked = true; version++; dispatches.push({ dispatchNo: 'DP-EXT', review: { status: 'PENDING' } }); return response(binding())
|
||||
}
|
||||
if (path.endsWith('/review')) {
|
||||
assert.equal(body.recordVersion, version); version++
|
||||
const number = path.split('/').at(-2)
|
||||
if (fault !== 'review-not-applied') dispatches.find(row => row.dispatchNo === number).review.status = 'APPROVED'
|
||||
return response(detail())
|
||||
}
|
||||
if (path.endsWith('/trips/TR-CURRENT')) return response(detail())
|
||||
if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: false, snapshotHash: 'b'.repeat(64), quotes: [
|
||||
{ bookingNo: 'FB-CURRENT', incomingNo: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, quantity: 1, unitPrice: 30 },
|
||||
] })
|
||||
if (path.endsWith('/transfer-delivery')) {
|
||||
if (body.fees.length) return response(null, '本排车全部货物已带入外接约定运费,请勿重复填写派送应收')
|
||||
const replayed = transferred; transferred = true
|
||||
return response({ dispatchNo: 'DP-EXT', outboundNo: 'OUT-EXT', packageCount: 1, deliveries: [{ deliveryNo: 'DEL-EXT' }], replayed })
|
||||
}
|
||||
assert.match(path, /\/delivery-orders\/DEL-EXT\?customerId=900719925474099301$/)
|
||||
const rows = [{ feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }]
|
||||
if (fault === 'duplicate-fee' && calls.filter(row => row.path === path).length > 1) rows.push({ ...rows[0] })
|
||||
return response({ order: { dispatchNo: 'DP-EXT' }, fees: rows })
|
||||
},
|
||||
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)
|
||||
function fixture(fault = '', nativeMode = false) {
|
||||
const calls = [], assertions = [], ledger = new Map()
|
||||
const externalCustomer = nativeMode ? OWN : EXT
|
||||
const destination = n => ({ key: `FBA:A${nativeMode ? 1 : n}`, warehouseCode: `A${nativeMode ? 1 : n}` })
|
||||
const item = (n, customerId = OWN) => ({ dispatchItemId: `11${n}`, sourceKey: `PLAN:2${n}`, sourceVersion: 'a'.repeat(64), sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, warehousePalletId: `3${n}`, palletNo: `PAL-${n}`,
|
||||
customerId, inboundCode: n === 3 ? 'WT-CURRENT' : nativeMode ? `WT-OWN-${n}` : `IN-${n}`, orderNo: n === 3 ? 'WT-CURRENT' : nativeMode ? `WT-OWN-${n}` : `ORDER-${n}`, destination: destination(n === 3 ? 1 : n), scheduledPackages: nativeMode ? 1 : n === 3 ? 3 : 2, pickedPackages: 0 })
|
||||
const items = [item(1), item(2, nativeMode ? EXT : OWN)], fullItems = [item(4)], external = item(3, externalCustomer)
|
||||
const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 4, weightKg: 57, volumeCbm: 1.1 }, remaining: { palletSpaces: 26, weightKg: 17943, volumeCbm: 88.9 } }
|
||||
const bookingCargo = { destinationKey: 'FBA:A1', packageCount: nativeMode ? 1 : 3, palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, priceBasis: 'PALLET', unitPrice: 15, currency: 'CNY' }
|
||||
const accepted = { source: { incomingNo: 'WT-CURRENT', inboundCode: 'WT-CURRENT', warehouseId: '92', destinationKey: 'FBA:A1', status: 'READY_FOR_DISPATCH', receiptHash: 'd'.repeat(64), receivedPackages: nativeMode ? 1 : 3,
|
||||
pallets: [{ warehousePalletId: '33', palletNo: 'PAL-3', packages: nativeMode ? 1 : 3, weightKg: 37, volumeCbm: 0.7 }] }, pallets: [{ warehousePalletId: '33', palletSpaces: 2, weightKg: 37, volumeCbm: 0.7 }], operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z' }
|
||||
const currentCase = { marker: 'CURRENT', mode: 'PARTIAL', dispatchNo: 'DS-OWN', customerId: OWN, transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '未覆盖派送费', amount: 17.5, currency: 'USD' }] } }
|
||||
const full = { ...clone(currentCase), mode: 'FULL', dispatchNo: 'DS-FULL' }
|
||||
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
|
||||
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,
|
||||
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' },
|
||||
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 = () => {
|
||||
const raw = items.map((i, index) => {
|
||||
const isExternal = i.sourceKey === 'PLAN:23', groupKey = nativeMode ? `G-${i.customerId}` : `G-${i.sourceKey}`
|
||||
return { groupKey, customerId: i.customerId, destination: i.destination, groupCargoHash: `${index + 1}`.repeat(64), feeCoverageCargoHash: `${index + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), blockReasons: [], requiredServices: isExternal ? [] : ['DELIVERY_FEE'],
|
||||
cargo: [{ ...i, sourceRegion: isExternal || nativeMode ? 'LASTLEG' : 'FIRSTLEG', sourceInboundId: isExternal || nativeMode ? null : `9007199254740994${index}`, orderId: `4${index}`, packages: i.scheduledPackages }],
|
||||
externalQuotes: isExternal ? [{ customerId: externalCustomer, groupKey, bookingNo: 'BK-CURRENT', inboundCode: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, sourceHash: 'b'.repeat(64) }] : [],
|
||||
feeSources: isExternal || nativeMode ? [] : [{ financeFeeId: `9007199254740995${index}`, customerId: OWN, currency: 'CNY', amount: 200, sourceVersion: 1, sourceHash: `FEE-${index}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [i.sourceKey] }] }
|
||||
})
|
||||
if (!nativeMode) return raw
|
||||
const merged = new Map()
|
||||
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'] : [] }))
|
||||
}
|
||||
return { context, tripProof, calls, assertions }
|
||||
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 () => {},
|
||||
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), 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 (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',
|
||||
cargo: items.map(i => ({ dispatchNo: 'DS-OWN', warehousePalletId: i.warehousePalletId, packages: i.scheduledPackages })),
|
||||
capacityPlan: { pallets: items.slice(0, 2).map((i, n) => ({ warehousePalletId: i.warehousePalletId, palletSpaces: 1, weightKg: 10, volumeCbm: 0.2, deliveryIndex: nativeMode ? 1 : n + 1 })) },
|
||||
availableSegments: planned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 25 } }] : [segment] })
|
||||
}
|
||||
if (path === `${TRIP}/bookings`) return response({ tripRecordVersion: fleetVersion, records: [booking()] })
|
||||
if (path === `${TRIP}/bookings/BK-CURRENT/dispatch`) { assert.equal(body.dispatchNo, 'DS-OWN'); assert.equal(body.tripRecordVersion, fleetVersion); assert.equal(body.recordVersion, 0); bound = true; fleetVersion++; return remember({ tripRecordVersion: fleetVersion, booking: booking(), replayed: false }) }
|
||||
if (path === TRIP) return response(trip())
|
||||
if (path === `${BASE}/candidates`) {
|
||||
assert.equal(params.get('warehouseId'), '92'); assert.equal(params.get('sourceStage'), 'RECEIVED'); assert.equal(params.get('orderNo'), 'WT-CURRENT')
|
||||
return response({ complete: true, snapshotAt: '2026-10-19', candidates: [{ selection: { sourceType: 'INBOUND_PLAN', planPalletId: '23', returnReceiptId: null, relabelNo: null, warehousePalletId: '33', sourceVersion: 'a'.repeat(64) },
|
||||
sourceKey: 'PLAN:23', sourceVersion: 'a'.repeat(64), customerId: fault === 'foreign-customer' ? '999' : externalCustomer, warehouseId: '92', inboundCode: 'WT-CURRENT', palletNo: 'PAL-3', availablePackages: nativeMode ? 1 : 3, destination: destination(1), appointmentBatches: [], blockReasons: [] }] })
|
||||
}
|
||||
if (path === BASE) return response({ records: [{ ...detail(params.get('dispatchNo') === 'DS-FULL'), warehouseId: '92' }] })
|
||||
if (raw.includes('/outbound-orders?')) return response({ records: [], total: 0, page: 1, size: 200 })
|
||||
if (path.includes('/finance-fee-sources')) {
|
||||
assert.equal(params.get('customerId'), externalCustomer); assert.equal(params.get('businessNo'), nativeMode ? 'DEL-0' : 'DEL-2')
|
||||
return response([{ id: '980', sourceFeeId: '970', customerId: fault === 'foreign-source-fee' ? externalCustomer === OWN ? EXT : OWN : externalCustomer, direction: 'RECEIVABLE', feeItemCode: 'DELIVERY_FEE', inboundCode: 'WT-CURRENT', businessNo: nativeMode ? 'DEL-0' : 'DEL-2', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'CNY', originalAmount: 30, chargeableQuantity: fault === 'wrong-quantity' ? 1 : 2, unitPrice: 15, quantityUnit: '托位' }])
|
||||
}
|
||||
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 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 }] })) })
|
||||
if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo })
|
||||
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)
|
||||
if (path.endsWith('/return-to-review')) { stage = 'BEFORE_LOADING'; review = false; recordVersion++; return remember(ack('RETURN_TO_REVIEW')) }
|
||||
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) })) }))
|
||||
} }
|
||||
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' }
|
||||
return { context, tripProof, currentCase, calls, assertions, items, options }
|
||||
}
|
||||
|
||||
test('external dispatch helper preserves reservation when binding physical cargo and publishes only agreed source fee', async () => {
|
||||
const f = fixture()
|
||||
const proof = await verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.dispatchNo, 'DP-EXT')
|
||||
assert.equal(proof.sourceFees.length, 1)
|
||||
assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED')
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(f.calls.every(row => !/\/pick|\/depart|\/cancel/.test(row.path)))
|
||||
await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof), /尚未执行关联/)
|
||||
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.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.items.length, 3); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /尚未装入/)
|
||||
})
|
||||
|
||||
for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee']) {
|
||||
test(`external dispatch proof blocks ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof))
|
||||
assert.equal(f.tripProof.externalDispatch.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed))
|
||||
})
|
||||
}
|
||||
for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'second-dispatch', 'already-loaded', 'unknown-add']) test(`native external proof rejects ${fault}`, async () => {
|
||||
const f = fixture(fault); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED')
|
||||
if (['foreign-customer', 'already-loaded'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false)
|
||||
if (fault === 'unknown-add') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/items'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) }
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
test('server binding requires the same existing whole dispatch and public cargo commands use versioned ADD_CARGO', () => {
|
||||
const service = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java', import.meta.url), 'utf8')
|
||||
assert.ok(service.includes('identity.tripId()!=trip.getId()')); assert.ok(service.includes('planning.validateIncomingDispatch'))
|
||||
const source = readFileSync(new URL('./fleet-external-dispatch-acceptance.mjs', import.meta.url), 'utf8')
|
||||
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 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), /尚未覆盖/)
|
||||
})
|
||||
|
||||
@@ -1,116 +1,244 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
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'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-external-dispatch-price', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const key = () => `fleet-dispatch-${randomUUID()}`
|
||||
const stable = rows => JSON.stringify(rows)
|
||||
|
||||
/** Continues a current-run VERIFIED external receipt. Leaves both dispatches ready for the caller's real PDA picking. */
|
||||
export async function verifyFleetExternalDispatch(context, module, tripProof) {
|
||||
const receipt = tripProof.externalReceipt
|
||||
if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch)
|
||||
throw new Error('外接关联只接受本轮已验证实收且尚未执行关联的车次')
|
||||
const proof = tripProof.externalDispatch = { status: 'CHECKING', marker: context.marker, requests: [] }
|
||||
const customerId = id(receipt.createRequest.customerId)
|
||||
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`
|
||||
const dispatchBase = '/api/platform/v1/last-leg/dispatches'
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
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)
|
||||
}
|
||||
}
|
||||
async function request(method, path, body, label, expectedMessage) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
const conflict = response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409')
|
||||
await check(label, response?.requestId && response?.traceId && (expectedMessage
|
||||
? conflict && expectedMessage.test(id(response?.body?.message)) : response?.http === 200 && id(response?.body?.code) === '200'), response)
|
||||
return response.body?.data
|
||||
}
|
||||
|
||||
// 仅将明确的客户/目的地配置映射到实时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))
|
||||
}
|
||||
const before = await request('GET', `${base}/capacity-plan`, undefined, '关联外接前冻结有效计划与预留')
|
||||
await check('实际外接仍计入承诺容量', before?.planValid && before.capacityPlan?.pallets?.length > 0
|
||||
&& stable(before.availableSegments) === stable(receipt.finalSegments))
|
||||
const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: receipt.incomingNo })}`,
|
||||
undefined, '查询本轮真实外接来货的可排车实托')
|
||||
await check('候选精确匹配本轮客户起运仓来货及实托', Array.isArray(candidates) && candidates.length === 1
|
||||
&& id(candidates[0].customerId) === customerId && id(candidates[0].overseasWarehouseId) === id(tripProof.warehouseId)
|
||||
&& candidates[0].inboundCode === receipt.incomingNo && id(candidates[0].warehousePalletId) === receipt.warehousePalletId
|
||||
&& candidates[0].palletNo === receipt.palletNo && Number(candidates[0].packageCount) === 1
|
||||
&& candidates[0].sourceStage === 'RECEIVED' && candidates[0].planPalletId)
|
||||
const candidate = candidates[0]
|
||||
proof.draftToken = key()
|
||||
const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: proof.draftToken, capacity: 30 }, '建立本轮外接排车草稿')
|
||||
await check('草稿返回明确容器引用', container?.id)
|
||||
proof.draftContainerId = id(container.id)
|
||||
await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidate.planPalletId),
|
||||
draftToken: proof.draftToken, draftContainerId: proof.draftContainerId }, '正式锁定本轮外接实托')
|
||||
proof.preArrangeRequest = { planPalletIds: [id(candidate.planPalletId)], draftToken: proof.draftToken }
|
||||
const arranged = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '从真实已收货来源生成外接客户排车')
|
||||
await check('外接排车只包含本票实收货物', arranged?.dispatchNo && arranged.dispatchNo !== tripProof.dispatchNo
|
||||
&& arranged.items?.length === 1 && id(arranged.items[0].warehousePalletId) === receipt.warehousePalletId
|
||||
&& Number(arranged.packageCount) === 1 && Number(arranged.pickedPackageCount) === 0 && Number(arranged.shippedPackageCount) === 0)
|
||||
proof.dispatchNo = arranged.dispatchNo
|
||||
const replay = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '重放外接预排不生成第二张排车')
|
||||
await check('预排重放保持排车与实托', replay?.dispatchNo === proof.dispatchNo && stable(replay.items) === stable(arranged.items))
|
||||
const bookingPath = `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}`
|
||||
const bookingList = await request('GET', `${base}/bookings`, undefined, '读取关联前最新预订及车次版本')
|
||||
const booking = bookingList?.records?.find(row => row.bookingNo === receipt.bookingNo)
|
||||
await check('本票仍为实收完成且未关联', booking?.status === 'RECEIVED' && booking.incomingNo === receipt.incomingNo && !booking.dispatchNo)
|
||||
proof.bindRequest = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion,
|
||||
dispatchNo: proof.dispatchNo, idempotencyKey: key() }
|
||||
const linked = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '将本票外接真实排车关联到承诺车次')
|
||||
await check('关联后预订绑定同一排车', linked?.booking?.status === 'BOUND' && linked.booking.dispatchNo === proof.dispatchNo)
|
||||
const linkedReplay = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '重放外接排车关联')
|
||||
await check('关联重放不增加版本或第二次占位', linkedReplay?.replayed === true
|
||||
&& id(linkedReplay.tripRecordVersion) === id(linked.tripRecordVersion) && stable(linkedReplay.booking) === stable(linked.booking))
|
||||
const after = await request('GET', `${base}/capacity-plan`, undefined, '读取新增真实货物后的计划容量')
|
||||
await check('新增外接货物使旧容量确认待重核', after?.planValid === false && after.cargo?.some(row => row.dispatchNo === proof.dispatchNo
|
||||
&& id(row.warehousePalletId) === receipt.warehousePalletId && Number(row.packages) === 1))
|
||||
const pallets = new Map(before.capacityPlan.pallets.map(row => [id(row.warehousePalletId), row]))
|
||||
const actual = receipt.finalBooking.receipt.source.pallets[0]
|
||||
pallets.set(receipt.warehousePalletId, { warehousePalletId: receipt.warehousePalletId, palletSpaces: 1,
|
||||
weightKg: Number(actual.weightKg), volumeCbm: Number(actual.volumeCbm),
|
||||
deliveryIndex: tripProof.stops.findIndex(stop => stop.destinationKey === receipt.finalBooking.cargo.destinationKey) })
|
||||
await check('新容量只引用原计划和已核实外接实测', after.cargo.every(row => pallets.has(id(row.warehousePalletId)))
|
||||
&& new Set(after.cargo.map(row => id(row.warehousePalletId))).size === pallets.size)
|
||||
const replanned = await request('POST', `${base}/capacity-plan`, { recordVersion: after.recordVersion, manifestHash: after.manifestHash,
|
||||
pallets: [...pallets.values()], reason: '外接预订转实际发运后按本轮实测重新确认,不重复计算原预留' }, '重新核对包含外接实物的全部货物容量')
|
||||
await check('从预留转实际关联后分段总占用守恒', replanned?.planValid === true && stable(replanned.availableSegments) === stable(before.availableSegments))
|
||||
let detail = await request('GET', base, undefined, '读取本车所有客户排车待审核情况')
|
||||
await check('自有货与外接客户排车均保留', detail?.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo)
|
||||
&& detail.dispatches.some(row => row.dispatchNo === proof.dispatchNo))
|
||||
for (const binding of detail.dispatches) detail = await request('POST', `${base}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`,
|
||||
{ recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }, '关联变化后逐客户通过调度审核')
|
||||
await check('每张客户执行单都有通过事实', detail.dispatches.every(row => row.review?.status === 'APPROVED'))
|
||||
const dispatchPath = `${dispatchBase}/${encodeURIComponent(proof.dispatchNo)}`
|
||||
const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '读取本票实收约定运费')
|
||||
await check('外接报价精确对应本票实收和确认单价', freight?.requiresDeliveryFee === false && freight.quotes?.length === 1
|
||||
&& freight.quotes[0].bookingNo === receipt.bookingNo && freight.quotes[0].incomingNo === receipt.incomingNo
|
||||
&& freight.quotes[0].currency === 'CNY' && Number(freight.quotes[0].amount) === 30
|
||||
&& Number(freight.quotes[0].quantity) === 1 && Number(freight.quotes[0].unitPrice) === 30)
|
||||
proof.transferRequest = { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19),
|
||||
deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK',
|
||||
vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate,
|
||||
driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF',
|
||||
remark: `${context.marker} 外接实收报价验收`, externalFreightHash: freight.snapshotHash, fees: [] }
|
||||
await request('POST', `${dispatchPath}/transfer-delivery`, { ...proof.transferRequest,
|
||||
idempotencyKey: key(), fees: [{ feeType: 'DELIVERY_FEE', feeName: '重复派送费测试', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] },
|
||||
'全部外接已有约定运费时拒绝重复派送应收', /重复|再次/)
|
||||
const transferred = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '按实收报价建立本票派送及外接应收来源')
|
||||
await check('外接只生成本目的地一张派送', transferred?.dispatchNo === proof.dispatchNo && transferred.outboundNo
|
||||
&& transferred.deliveries?.length === 1 && Number(transferred.packageCount) === 1)
|
||||
proof.outboundNo = transferred.outboundNo; proof.deliveryNo = transferred.deliveries[0].deliveryNo
|
||||
const deliveryPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(customerId)}`
|
||||
const delivery = await request('GET', deliveryPath, undefined, '回查本票派送费用来源')
|
||||
const fees = delivery?.fees?.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE')
|
||||
await check('本票只登记约定30元应收来源且没有伪零费用', fees?.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY'
|
||||
&& fees[0].sourceStatus === 'ACTIVE' && fees[0].reviewStatus === 'APPROVED' && delivery.order?.dispatchNo === proof.dispatchNo)
|
||||
const transferReplay = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '外接转派送原请求重放')
|
||||
const replayedDelivery = await request('GET', deliveryPath, undefined, '重放后回查外接应收未重复')
|
||||
await check('外接重放保持派送及费用来源', transferReplay?.replayed === true && transferReplay.outboundNo === proof.outboundNo
|
||||
&& stable(replayedDelivery.fees) === stable(delivery.fees))
|
||||
proof.customerId = customerId; proof.finalTripVersion = detail.trip.recordVersion
|
||||
proof.sourceFees = fees; proof.financeAcknowledgement = 'NOT_VERIFIED'
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
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 } = {}) {
|
||||
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 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) }
|
||||
try {
|
||||
let current = await readDispatch(io, tripProof.dispatchNo)
|
||||
io.check('只调整本轮同仓零扫码原主单', null, id(current.warehouseId) === id(tripProof.warehouseId) && current.data.status === 'ACTIVE'
|
||||
&& ['BEFORE_LOADING', 'LOADING'].includes(current.data.stage) && Number(current.data.counts.pickedPackageCount) === 0 && Number(current.data.counts.shippedPackageCount) === 0)
|
||||
const ownItems = current.items.items.map(identity)
|
||||
const before = await io.get(`${base}/capacity-plan`, '冻结外接预留与真实原容量计划')
|
||||
io.check('原容量和外接承诺均完整', null, before?.planValid === true && before.capacityPlan?.pallets?.length > 0 && JSON.stringify(before.availableSegments) === JSON.stringify(receipt.finalSegments))
|
||||
let list = await io.get(`${base}/bookings`, '核对运营确认后的原booking和原报价')
|
||||
const booking = list.records?.find(row => row.bookingNo === receipt.bookingNo), accepted = booking?.receipt
|
||||
io.check('实时booking保持本轮客户仓实收和运营确认', null, booking?.status === 'RECEIVED' && !booking.dispatchNo && booking.incomingNo === receipt.incomingNo
|
||||
&& id(booking.customerId) === id(receipt.createRequest?.customerId) && accepted?.source?.warehouseId === id(tripProof.warehouseId)
|
||||
&& accepted.source.incomingNo === receipt.incomingNo && accepted.source.status === 'READY_FOR_DISPATCH' && accepted.source.receiptHash
|
||||
&& accepted.source.pallets?.length > 0 && Array.isArray(accepted.pallets) && accepted.operatorId && Number.isFinite(Date.parse(accepted.confirmedAt))
|
||||
&& JSON.stringify(booking.cargo) === JSON.stringify(receipt.finalBooking.cargo) && String(booking.agreedAmount) === String(receipt.finalBooking.agreedAmount))
|
||||
const actual = accepted.source.pallets, packages = actual.reduce((n, p) => n + Number(p.packages), 0)
|
||||
io.check('真实来货托盘和箱数守恒', null, new Set(actual.map(p => id(p.warehousePalletId))).size === actual.length && packages === Number(accepted.source.receivedPackages)
|
||||
&& actual.every(p => p.warehousePalletId && p.palletNo && Number.isSafeInteger(Number(p.packages)) && Number(p.packages) > 0)
|
||||
&& actual.some(p => id(p.warehousePalletId) === id(receipt.warehousePalletId)))
|
||||
const page = await candidatePage(io, tripProof.warehouseId, { orderNo: receipt.incomingNo })
|
||||
const candidates = page.filter(c => c.inboundCode === receipt.incomingNo && id(c.customerId) === id(booking.customerId)
|
||||
&& id(c.warehouseId) === id(tripProof.warehouseId) && actual.some(p => id(p.warehousePalletId) === id(c.selection?.warehousePalletId)))
|
||||
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, '零扫码明确退回审核')
|
||||
await commandReplay(`${path}/return-to-review`, proof.returnRequest, returned, '退回审核原键重放')
|
||||
current = await readDispatch(io, tripProof.dispatchNo)
|
||||
}
|
||||
io.check('同一主单已处于装车前可加货', null, current.data.stage === 'BEFORE_LOADING')
|
||||
proof.addRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('add-real-incoming'), cargo: addition.cargo, batchAllocations: addition.batchAllocations }
|
||||
const added = await io.request('POST', `${path}/items`, proof.addRequest, '真实已运营确认来源ADD_CARGO加入原主单')
|
||||
io.check('加货命令保持原整车身份', null, added.dispatchNo === tripProof.dispatchNo && added.action === 'ADD_CARGO')
|
||||
await commandReplay(`${path}/items`, proof.addRequest, added, '同主单加货原键重放')
|
||||
current = await readDispatch(io, tripProof.dispatchNo)
|
||||
io.check('加货和重放保留原客户物理行,仅增加本次完整来源', null, current.data.stage === 'BEFORE_LOADING'
|
||||
&& current.items.items.length === ownItems.length + candidates.length && ownItems.every(i => current.items.items.some(j => JSON.stringify(identity(j)) === JSON.stringify(i)))
|
||||
&& candidates.every(c => current.items.items.some(i => i.sourceKey === c.sourceKey && i.sourceVersion === c.sourceVersion && id(i.customerId) === id(booking.customerId)
|
||||
&& id(i.warehousePalletId) === id(c.selection.warehousePalletId) && i.inboundCode === receipt.incomingNo && Number(i.scheduledPackages) === Number(c.availablePackages) && Number(i.pickedPackages) === 0)))
|
||||
list = await io.get(`${base}/bookings`, '加货后刷新booking与真实车次版本')
|
||||
const latest = list.records.find(b => b.bookingNo === receipt.bookingNo)
|
||||
proof.bindRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(latest.recordVersion), dispatchNo: tripProof.dispatchNo, idempotencyKey: io.key('bind-same-whole') }
|
||||
const bound = await io.request('POST', `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, '原booking绑定本车唯一整车主单')
|
||||
io.check('实收转关联只指向原主单', null, bound.booking?.status === 'BOUND' && bound.booking.dispatchNo === tripProof.dispatchNo && bound.booking.bookingNo === receipt.bookingNo
|
||||
&& JSON.stringify(bound.booking.cargo) === JSON.stringify(booking.cargo) && String(bound.booking.agreedAmount) === String(booking.agreedAmount))
|
||||
await commandReplay(`${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, bound, '同主单booking关联原键重放')
|
||||
const after = await io.get(`${base}/capacity-plan`, '真实加货和预留转关联后重新核对容量')
|
||||
io.check('实际新增货物使旧容量计划失效且没有第二张客户排车', null, after.planValid === false
|
||||
&& after.cargo.every(c => c.dispatchNo === tripProof.dispatchNo) && candidates.every(c => after.cargo.some(i => id(i.warehousePalletId) === id(c.selection.warehousePalletId) && Number(i.packages) === Number(c.availablePackages))))
|
||||
const pallets = new Map(before.capacityPlan.pallets.map(p => [id(p.warehousePalletId), clone(p)]))
|
||||
for (const p of actual) {
|
||||
const measurement = accepted.pallets.find(m => id(m.warehousePalletId) === id(p.warehousePalletId)), index = current.route.stops.findIndex(s => s.destinationKey === booking.cargo.destinationKey)
|
||||
io.check('新托仅使用实际运营确认托位和来货实测', null, measurement && Number.isSafeInteger(measurement.palletSpaces) && measurement.palletSpaces > 0
|
||||
&& Number(measurement.weightKg ?? p.weightKg) > 0 && Number(measurement.volumeCbm ?? p.volumeCbm) > 0 && index > 0)
|
||||
pallets.set(id(p.warehousePalletId), { warehousePalletId: id(p.warehousePalletId), palletSpaces: measurement.palletSpaces,
|
||||
weightKg: measurement.weightKg ?? p.weightKg, volumeCbm: measurement.volumeCbm ?? p.volumeCbm, deliveryIndex: index })
|
||||
}
|
||||
io.check('新容量逐托覆盖全部实际货物且没有额外资源', null, new Set(after.cargo.map(c => id(c.warehousePalletId))).size === pallets.size && after.cargo.every(c => pallets.has(id(c.warehousePalletId))))
|
||||
proof.capacityRequest = { recordVersion: version(after.recordVersion), manifestHash: after.manifestHash, pallets: [...pallets.values()], reason: '本轮同主单外接实收按实际运营确认和原计划量重新核对' }
|
||||
const planned = await io.request('POST', `${base}/capacity-plan`, proof.capacityRequest, '重新确认实际货物容量')
|
||||
io.check('预留转实际后分段占用守恒', null, planned.planValid === true && JSON.stringify(planned.availableSegments) === JSON.stringify(before.availableSegments))
|
||||
const trip = await io.get(base, '复审前核对资源和唯一整车关联')
|
||||
io.check('原车辆司机起运仓和唯一排车保持不变', null, trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === tripProof.dispatchNo
|
||||
&& id(trip.trip?.warehouseId) === id(tripProof.warehouseId) && id(trip.trip.vehicleId) === id(tripProof.vehicleId) && id(trip.trip.driverId) === id(tripProof.driverId))
|
||||
tripProof.stops = trip.trip.stops
|
||||
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
|
||||
const detail = await io.get(path, `${action}前刷新原主单版本`)
|
||||
const body = { recordVersion: version(detail.recordVersion), idempotencyKey: io.key(action), ...(action === 'submit-review' ? { remark: '本轮加入外接实物后全车重新提交' } : { decision: 'APPROVED', reason: '本轮外接实收全车重新审核' }) }
|
||||
const result = await io.request('POST', `${path}/${action}`, body, `整车${action}`)
|
||||
await commandReplay(`${path}/${action}`, body, result, `整车${action}原键重放`)
|
||||
}
|
||||
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)))
|
||||
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))}`, '回查本客户外接应收来源')
|
||||
const sources = await io.get(`/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: id(booking.customerId), businessType: 'DELIVERY_ORDER', businessNo: proof.deliveryNo })}`, '按本次客户派送业务号回查外接应收来源')
|
||||
const fees = Array.isArray(sources) ? sources.filter(f => f.direction === 'RECEIVABLE' && f.feeItemCode === 'DELIVERY_FEE' && f.sourceStatus === 'ACTIVE' && f.inboundCode === receipt.incomingNo) : []
|
||||
const quantity = expected.cargo.priceBasis === 'PALLET' ? expected.cargo.palletSpaces : expected.cargo.priceBasis === 'ORDER' ? 1 : null
|
||||
io.check('外接只登记本次约定金额应收且来源已审核', null, fees.length === 1 && money(fees[0].originalAmount) === money(expected.agreedAmount)
|
||||
&& 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
|
||||
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,108 +1,122 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { verifyFleetFeeCoverage, coveragePlan } from './fleet-fee-coverage-acceptance.mjs'
|
||||
|
||||
function fixture(mode = 'FULL', fault = '') {
|
||||
const source = { marker: 'CURRENT-TEST', mode, dispatchNo: 'D-CURRENT', customerId: '900719925474099301',
|
||||
transferBody: { loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [
|
||||
{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' },
|
||||
] } }
|
||||
const cargo = [1, 2].map(n => ({ dispatchItemId: `item-${n}`, warehousePalletId: `pallet-${n}`, orderId: `order-${n}`,
|
||||
sourceRegion: 'FIRSTLEG', sourceInboundId: `90071992547409940${n}`, inboundCode: `IN-${n}`, packages: 2 }))
|
||||
const fees = cargo.map((row, index) => ({ financeFeeId: `90071992547409950${index}`, sourceVersion: 1, sourceHash: `fee-hash-${index}`,
|
||||
sourceInboundId: row.sourceInboundId, customerId: source.customerId, sourceType: 'INBOUND_FEE', currency: 'CNY', amount: 200, status: 'AVAILABLE' }))
|
||||
if (fault === 'foreign-customer') fees[0].customerId = 'different-customer'
|
||||
if (fault === 'missing-source') fees.splice(1)
|
||||
const hash = 'a'.repeat(64), calls = [], assertions = []
|
||||
let savedBody, replayed = false, dirtyOutbound = false
|
||||
const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data }, requestId: 'req-test', traceId: 'trace-test' })
|
||||
const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: [source] },
|
||||
assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
if (method === 'GET' && path === '/api/platform/v1/last-leg/dispatches/D-CURRENT')
|
||||
return response({ dispatchNo: source.dispatchNo, pickedPackageCount: 0, shippedPackageCount: 0,
|
||||
items: cargo.map((row, index) => ({ id: row.dispatchItemId, packageCount: row.packages,
|
||||
destinationWarehouseCode: `FBA-${index + 1}`, fbaWarehouseCode: `ORIGINAL-FBA-${index + 1}` })) })
|
||||
if (method === 'GET' && path.endsWith('/fee-coverage')) return response({ dispatchNo: source.dispatchNo, sourceComplete: true,
|
||||
cargoHash: hash, cargo, feeSources: fees.map(row => fault === 'mutated-original' && savedBody ? { ...row, amount: 201 } : row) })
|
||||
if (method === 'GET' && path.includes('/outbound-orders?')) {
|
||||
const params = new URL(path, 'https://test.invalid').searchParams
|
||||
assert.equal(params.get('customerId'), source.customerId)
|
||||
assert.equal(params.has('dispatchNo'), false, 'outbound list does not support dispatchNo filtering')
|
||||
const rows = dirtyOutbound ? [{ outboundNo: 'OUT-WRONG', dispatchNo: source.dispatchNo }] : [{ outboundNo: 'OUT-OTHER', dispatchNo: 'DS-OTHER' }]
|
||||
return response({ records: rows, total: rows.length, page: 1, size: 200 })
|
||||
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 })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/platform/v1/last-leg/outbound-orders/OUT-CURRENT') return response({
|
||||
outbound: { outboundNo: 'OUT-CURRENT', dispatchNo: source.dispatchNo, status: 'WAIT_OUTBOUND' },
|
||||
deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY' })),
|
||||
})
|
||||
if (method === 'GET' && path.endsWith('/external-freight')) return response({ snapshotHash: 'b'.repeat(64), quotes: [], requiresDeliveryFee: true })
|
||||
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')) {
|
||||
if (body.feeCoverageCargoHash !== hash) {
|
||||
if (fault === 'failed-command-writes') dirtyOutbound = true
|
||||
return response(null, '原费用对应货物已变化,请重新核对覆盖范围')
|
||||
}
|
||||
if (body.feeCoverage[0].sourceVersion !== 1) return response(null, '原费用版本已变化,请重新确认服务覆盖范围')
|
||||
if (mode === 'FULL' && body.fees.length) return response(null, '本次货物的该服务已由原费用覆盖,请勿重复登记应收')
|
||||
if (mode === 'PARTIAL' && !body.fees.length) return response(null, '尚未有约定运费的货物须填写派送费')
|
||||
replayed = Boolean(savedBody)
|
||||
if (savedBody) assert.deepEqual(body, savedBody)
|
||||
savedBody = structuredClone(body)
|
||||
return response({ dispatchNo: source.dispatchNo, outboundNo: 'OUT-CURRENT', outboundStatus: 'WAIT_OUTBOUND', replayed,
|
||||
deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, deliveryStatus: 'WAIT_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)
|
||||
}
|
||||
const detail = path.match(/^\/api\/platform\/v1\/last-leg\/delivery-orders\/DEL-([12])\?customerId=900719925474099301$/)
|
||||
assert.ok(detail, `Unexpected ${method} ${path}`)
|
||||
const index = Number(detail[1]) - 1
|
||||
const selected = savedBody.feeCoverage.filter(selection => selection.financeFeeId === fees[index].financeFeeId)
|
||||
const references = selected.map(selection => ({ source: fees[index], services: selection.services }))
|
||||
const shouldCharge = mode === 'PARTIAL' && (fault === 'charge-covered-destination' ? index === 0 : index === 1)
|
||||
const newFee = { feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, sourceStatus: 'ACTIVE' }
|
||||
return response({ order: { dispatchNo: source.dispatchNo, deliveryNo: `DEL-${index + 1}`, destinationWarehouseCode: `FBA-${index + 1}` },
|
||||
fees: shouldCharge || (fault === 'duplicate-new-fee' && index === 0) || (fault === 'replay-adds-fee' && replayed) ? [newFee] : [],
|
||||
feeCoverage: { cargoHash: hash, operatorId: 'op-test', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-03T02:00:00Z' : '2026-10-03T01:00:00Z',
|
||||
cargo: fault === 'wrong-cargo' ? [cargo[0]] : [cargo[index]], references: fault === 'missing-reference' ? [] : references } })
|
||||
},
|
||||
}
|
||||
return { source, context, calls, assertions, clearFault() { fault = '' } }
|
||||
assert.equal(method, 'GET', raw); assert.equal(path, `${BASE}/${number}`); return response(detail(state))
|
||||
} }
|
||||
return { context, source, cases, calls, assertions, states, groups, clearFault() { fault = '' } }
|
||||
}
|
||||
|
||||
test('persisted reconciliation reads exact original documents and only replays the original successful command', async () => {
|
||||
const f = fixture('FULL', 'wrong-cargo')
|
||||
await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source))
|
||||
assert.equal(f.source.proof.outboundNo, 'OUT-CURRENT')
|
||||
const originalKey = f.source.proof.confirmationRequest.idempotencyKey
|
||||
const before = f.calls.length
|
||||
f.clearFault()
|
||||
const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
const commands = f.calls.slice(before).filter(row => row.method === 'POST')
|
||||
assert.ok(commands.length > 0)
|
||||
assert.ok(commands.every(row => row.body.idempotencyKey === originalKey))
|
||||
assert.ok(f.calls.slice(before).some(row => row.method === 'GET' && row.path.endsWith('/outbound-orders/OUT-CURRENT')))
|
||||
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 mode of ['FULL', 'PARTIAL']) {
|
||||
test(`${mode} fee coverage helper checks source scope, pre-write conflicts, each destination and replay`, async () => {
|
||||
const f = fixture(mode)
|
||||
const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source)
|
||||
assert.equal(proof.status, 'VERIFIED')
|
||||
assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5)
|
||||
assert.deepEqual(proof.deliveryNos, ['DEL-1', 'DEL-2'])
|
||||
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
||||
assert.ok(f.calls.every(call => !/cancel|receipt|departure/.test(call.path)))
|
||||
await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/)
|
||||
})
|
||||
}
|
||||
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 ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference',
|
||||
'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee']) {
|
||||
test(`fee coverage HTTP proof cannot pass ${fault}`, async () => {
|
||||
const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault)
|
||||
await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source))
|
||||
assert.equal(f.source.proof.status, 'CHECKING')
|
||||
assert.ok(f.assertions.some(row => !row.passed))
|
||||
if (['foreign-customer', 'missing-source'].includes(fault)) assert.equal(f.calls.some(row => row.method === 'POST'), false)
|
||||
})
|
||||
}
|
||||
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'))
|
||||
})
|
||||
|
||||
@@ -1,167 +1,214 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
import { pickAll } from './phase5-overseas-fulfillment.mjs'
|
||||
import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const OPTIONS = { scenario: 'fleet-original-fee-coverage', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const ok = response => response?.http === 200 && id(response?.body?.code) === '200'
|
||||
const businessConflict = response => response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409')
|
||||
const canonical = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort())
|
||||
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 = row => ({ financeFeeId: id(row.financeFeeId), sourceVersion: Number(row.sourceVersion), sourceHash: row.sourceHash,
|
||||
sourceInboundId: id(row.sourceInboundId), customerId: id(row.customerId), currency: row.currency, amount: Number(row.amount), status: row.status })
|
||||
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() })
|
||||
|
||||
/** Dedicated current-run dispatches only: one FULL case and a separate PARTIAL case with >=2 actual inbound sources. */
|
||||
export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false } = {}) {
|
||||
if (!context.createdResources?.fleetFeeCoverageCases?.includes(source) || source.marker !== context.marker || (source.proof && !reconcilePersisted)
|
||||
|| !['FULL', 'PARTIAL'].includes(source.mode) || !source.dispatchNo || !source.customerId)
|
||||
throw new Error('原费用验收需要本轮专用、未执行的排车用例;失败后保留原请求,不重复创建')
|
||||
if (reconcilePersisted && (source.mode !== 'FULL' || source.proof?.status !== 'CHECKING'
|
||||
|| !source.proof.outboundNo || !source.proof.deliveryNos?.length || !source.proof.confirmationRequest?.idempotencyKey))
|
||||
throw new Error('已落单核对须有原成功请求、出库单及派送集合,不允许重造')
|
||||
const proof = reconcilePersisted ? source.proof : source.proof = { status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] }
|
||||
const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(source.dispatchNo)}`
|
||||
const outboundPath = `/api/platform/v1/last-leg/outbound-orders?size=200&customerId=${encodeURIComponent(source.customerId)}`
|
||||
async function check(name, passed, response) {
|
||||
context.assert(module, name, response, Boolean(passed), '', OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${name}`)
|
||||
}
|
||||
async function request(method, path, body, label, expectedMessage) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
await check(label, Boolean(response?.requestId) && Boolean(response?.traceId)
|
||||
&& (expectedMessage ? businessConflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response)
|
||||
export function fleetConsumerIo(context, module, proof, namespace) {
|
||||
const io = dispatchIo(context, module, proof, namespace)
|
||||
const request = async (method, path, body, label, expected) => {
|
||||
const response = await io.send(method, path, body, label)
|
||||
io.check(`${label}诊断回执完整`, response, Boolean(response?.requestId) && Boolean(response?.traceId))
|
||||
io.check(label, response, expected ? [200, 409].includes(response.http) && String(response.body?.code) === 'A0409' && expected.test(String(response.body?.message))
|
||||
: response.http === 200 && String(response.body?.code) === '200')
|
||||
return response.body?.data
|
||||
}
|
||||
async function noOutbound(label) {
|
||||
const rows = []
|
||||
let total
|
||||
for (let index = 1; ; index++) {
|
||||
const page = await request('GET', `${outboundPath}&page=${index}`, undefined, label)
|
||||
await check('客户出库列表分页元数据完整', Array.isArray(page?.records) && Number.isSafeInteger(Number(page.total))
|
||||
&& Number(page.total) >= 0 && Number(page.page) === index && Number(page.size) === 200)
|
||||
total ??= Number(page.total)
|
||||
await check('客户出库列表跨页范围稳定', total === Number(page.total) && (page.records.length > 0 || rows.length === total))
|
||||
rows.push(...page.records)
|
||||
if (rows.length >= total) break
|
||||
return { ...io, request, get: (path, label) => request('GET', path, undefined, label) }
|
||||
}
|
||||
|
||||
export async function readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements } = {}) {
|
||||
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)
|
||||
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
|
||||
&& 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) })
|
||||
}
|
||||
await check('失败命令没有生成本排车出库事实', rows.length === total
|
||||
&& new Set(rows.map(row => row.outboundNo)).size === rows.length && rows.every(row => row.dispatchNo !== source.dispatchNo))
|
||||
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)
|
||||
}
|
||||
const dispatch = await request('GET', dispatchPath, undefined, '核对本轮费用验收排车来源')
|
||||
await check('只处理本轮尚未拣货及发车的排车', dispatch?.dispatchNo === source.dispatchNo
|
||||
&& Number(dispatch.pickedPackageCount) === 0
|
||||
&& Number(dispatch.shippedPackageCount) === 0 && dispatch.items?.length > 0)
|
||||
if (!reconcilePersisted) await noOutbound('确认专用排车尚无出库单')
|
||||
const preview = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '读取本次实际货物及原 Finance 费用来源')
|
||||
await check('原费用范围完整且可确认', preview?.sourceComplete === true && !preview.blockReason
|
||||
&& preview.dispatchNo === source.dispatchNo && /^[0-9a-f]{64}$/.test(preview.cargoHash || '')
|
||||
&& preview.cargo?.length > 0 && preview.cargo.every(row => row.sourceRegion === 'FIRSTLEG' && id(row.sourceInboundId)))
|
||||
const itemTotals = new Map()
|
||||
for (const row of preview.cargo) itemTotals.set(id(row.dispatchItemId), (itemTotals.get(id(row.dispatchItemId)) || 0) + Number(row.packages))
|
||||
await check('费用预览逐条排车箱数核平', itemTotals.size === dispatch.items.length
|
||||
&& dispatch.items.every(row => itemTotals.get(id(row.id)) === Number(row.packageCount)))
|
||||
const inboundIds = [...new Set(preview.cargo.map(row => id(row.sourceInboundId)))].sort()
|
||||
await check('原费用均属于当前客户和实际原入仓', Array.isArray(preview.feeSources) && preview.feeSources.every(fee =>
|
||||
id(fee.customerId) === id(source.customerId) && inboundIds.includes(id(fee.sourceInboundId))
|
||||
&& ['AVAILABLE', 'SETTLED'].includes(fee.status) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(fee.sourceType)
|
||||
&& fee.currency === 'CNY' && Number(fee.sourceVersion) >= 1 && fee.sourceHash && id(fee.financeFeeId)))
|
||||
const sources = inboundIds.map(inboundId => preview.feeSources.find(fee => id(fee.sourceInboundId) === inboundId))
|
||||
await check('本轮每票已产生可引用原费用,不借其他票费用补齐', sources.every(Boolean)
|
||||
&& (source.mode === 'FULL' || inboundIds.length >= 2))
|
||||
const selectedFees = source.mode === 'FULL' ? sources : [sources[0]]
|
||||
const selections = selectedFees.map(fee => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: Number(fee.sourceVersion),
|
||||
sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))
|
||||
const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '核对本专用排车没有外接报价混入覆盖用例')
|
||||
await check('本用例为原费用货物且使用托盘派送自有预约', Array.isArray(freight?.quotes) && freight.quotes.length === 0
|
||||
&& freight.requiresDeliveryFee === true && source.transferBody?.loadingType === 'PALLET'
|
||||
&& source.transferBody.appointmentAccount === 'SELF' && source.transferBody.fees?.length === 1
|
||||
&& source.transferBody.fees[0].direction === 'RECEIVABLE' && source.transferBody.fees[0].feeType === 'DELIVERY_FEE'
|
||||
&& Number(source.transferBody.fees[0].amount) > 0)
|
||||
const body = reconcilePersisted ? proof.confirmationRequest : { ...source.transferBody, idempotencyKey: `fleet-fee-${randomUUID()}`,
|
||||
externalFreightHash: freight.snapshotHash, feeCoverageCargoHash: preview.cargoHash, feeCoverage: selections,
|
||||
fees: source.mode === 'FULL' ? [] : source.transferBody.fees }
|
||||
proof.confirmationRequest = body
|
||||
if (!reconcilePersisted) proof.originalFees = preview.feeSources.map(feeIdentity)
|
||||
const transferPath = `${dispatchPath}/transfer-delivery`
|
||||
let transferred
|
||||
if (reconcilePersisted) {
|
||||
await check('原成功请求的货物与引用仍对应本次核对范围', body.feeCoverageCargoHash === preview.cargoHash
|
||||
&& canonical(body.feeCoverage) === canonical(selections))
|
||||
const stored = await request('GET', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, undefined, '先核对已持久化的原出库与派送集合')
|
||||
await check('原出库和派送存在且尚未执行', stored?.outbound?.dispatchNo === source.dispatchNo
|
||||
&& stored.outbound.outboundNo === proof.outboundNo && stored.outbound.status === 'WAIT_OUTBOUND'
|
||||
&& canonical((stored.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)
|
||||
&& stored.deliveries.every(row => row.status === 'WAIT_DELIVERY'))
|
||||
transferred = await request('POST', transferPath, body, '确认原持久化单据后仅重放同键同载荷成功命令')
|
||||
await check('核对重放未新建任何业务单据', transferred?.replayed === true && transferred.outboundNo === proof.outboundNo
|
||||
&& canonical((transferred.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos))
|
||||
} else {
|
||||
const staleHash = `${preview.cargoHash[0] === '0' ? '1' : '0'}${preview.cargoHash.slice(1)}`
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `stale-cargo-${randomUUID()}`, feeCoverageCargoHash: staleHash },
|
||||
'货物摘要陈旧时拒绝转派送', /货物已变化/)
|
||||
await noOutbound('陈旧货物拒绝后回查无业务写入')
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `stale-fee-${randomUUID()}`,
|
||||
feeCoverage: selections.map((row, index) => index === 0 ? { ...row, sourceVersion: row.sourceVersion + 1 } : row) },
|
||||
'原费用版本陈旧时拒绝确认', /原费用版本已变化/)
|
||||
await noOutbound('陈旧原费用拒绝后回查无业务写入')
|
||||
await request('POST', transferPath, { ...body, idempotencyKey: `fee-scope-${randomUUID()}`,
|
||||
fees: source.mode === 'FULL' ? source.transferBody.fees : [] },
|
||||
source.mode === 'FULL' ? '全覆盖禁止再登记派送应收' : '部分覆盖仍须登记未覆盖派送费',
|
||||
source.mode === 'FULL' ? /已由原费用覆盖/ : /须填写派送费/)
|
||||
await noOutbound('覆盖金额校验拒绝后回查无业务写入')
|
||||
transferred = await request('POST', transferPath, body, '正式保存本次原费用服务覆盖并转派送')
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
await check('转派送生成本次真实出库和各目的地派送', transferred?.dispatchNo === source.dispatchNo && transferred.outboundNo
|
||||
&& transferred.outboundStatus === 'WAIT_OUTBOUND' && transferred.deliveries?.length > 0
|
||||
&& transferred.deliveries.every(row => row.deliveryNo && row.deliveryStatus === 'WAIT_DELIVERY'))
|
||||
proof.outboundNo = transferred.outboundNo
|
||||
proof.deliveryNos = transferred.deliveries.map(row => row.deliveryNo).sort()
|
||||
const evidenceByDelivery = {}
|
||||
const feesByDelivery = {}
|
||||
let newReceivable = 0
|
||||
const allCargo = []
|
||||
for (const number of proof.deliveryNos) {
|
||||
const detailPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`
|
||||
const detail = await request('GET', detailPath, undefined, '回查派送详情中的原费用确认快照')
|
||||
const evidence = detail?.feeCoverage
|
||||
const destination = id(detail?.order?.destinationWarehouseCode || detail?.order?.destinationWarehouseId)
|
||||
const destinationItems = new Set(dispatch.items.filter(row => id(row.destinationWarehouseCode || row.fbaWarehouseCode) === destination).map(row => id(row.id)))
|
||||
await check('费用确认货物只属于当前派送目的地', destination && destinationItems.size > 0
|
||||
&& evidence?.cargo?.every(row => destinationItems.has(id(row.dispatchItemId))))
|
||||
await check('确认快照属于本次派送及实际原费用版本', detail?.order?.dispatchNo === source.dispatchNo
|
||||
&& detail.order.deliveryNo === number && evidence?.cargoHash === preview.cargoHash
|
||||
&& evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) && evidence.cargo?.length > 0
|
||||
&& Array.isArray(evidence.references) && evidence.references.every(ref => evidence.cargo.some(row => id(row.sourceInboundId) === id(ref.source?.sourceInboundId)) && selectedFees.some(fee =>
|
||||
id(fee.financeFeeId) === id(ref.source?.financeFeeId) && Number(fee.sourceVersion) === Number(ref.source?.sourceVersion)
|
||||
&& fee.sourceHash === ref.source?.sourceHash && canonical(ref.services || []) === canonical(['DELIVERY_FEE']))))
|
||||
const selectedInbounds = new Set(selectedFees.map(fee => id(fee.sourceInboundId)))
|
||||
await check('快照保留本派送已选择的每票服务引用', evidence.cargo.every(row => !selectedInbounds.has(id(row.sourceInboundId))
|
||||
|| evidence.references.some(ref => id(ref.source?.sourceInboundId) === id(row.sourceInboundId) && ref.services.includes('DELIVERY_FEE'))))
|
||||
await check('费用明细为明确应收列表', Array.isArray(detail.fees) && detail.fees.every(row => row.feeDirection && row.feeType))
|
||||
const added = detail.fees.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE' && row.sourceStatus !== 'VOIDED')
|
||||
await check('已全覆盖目的地不承担其他目的地的新增派送费', !evidence.cargo.every(row => selectedInbounds.has(id(row.sourceInboundId))) || added.length === 0)
|
||||
await check('新增派送费没有伪零值或混合币种', added.every(row => Number(row.amount) > 0 && row.currency === source.transferBody.fees[0].currency))
|
||||
newReceivable += added.reduce((sum, row) => sum + Number(row.amount), 0)
|
||||
allCargo.push(...evidence.cargo.map(cargoIdentity))
|
||||
evidenceByDelivery[number] = evidence
|
||||
feesByDelivery[number] = canonical(detail.fees)
|
||||
}
|
||||
await check('多目的地快照汇总与实际原货物一致且不重复', canonical(allCargo) === canonical(preview.cargo.map(cargoIdentity)))
|
||||
await check('仅未覆盖部分产生本次新增应收', Math.round(newReceivable * 100)
|
||||
=== (source.mode === 'FULL' ? 0 : Math.round(Number(source.transferBody.fees[0].amount) * 100)))
|
||||
const replay = await request('POST', transferPath, body, '原费用确认成功后同键重放')
|
||||
await check('重放保持同一出库和派送集合', replay?.replayed === true && replay.outboundNo === proof.outboundNo
|
||||
&& canonical((replay.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos))
|
||||
for (const number of proof.deliveryNos) {
|
||||
const detail = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`, undefined, '重放后回查覆盖证据')
|
||||
await check('重放未改写确认人时间和原费用快照', JSON.stringify(detail.feeCoverage) === JSON.stringify(evidenceByDelivery[number]))
|
||||
await check('成功命令重放未追加费用', Array.isArray(detail.fees) && canonical(detail.fees) === feesByDelivery[number])
|
||||
}
|
||||
const after = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '回查引用原费用未改变其来源事实')
|
||||
await check('原费用版本金额与状态保持不变', canonical(after.feeSources.map(feeIdentity)) === canonical(proof.originalFees))
|
||||
proof.snapshots = evidenceByDelivery
|
||||
proof.newReceivable = newReceivable
|
||||
proof.status = 'VERIFIED'
|
||||
return proof
|
||||
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('原费用验收需要本轮专用、未执行的排车用例;保留原请求')
|
||||
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('续验须有原成功命令和已持久化单据,不可重造')
|
||||
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)}`
|
||||
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
|
||||
} catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error }
|
||||
}
|
||||
|
||||
@@ -6,23 +6,34 @@ import { verifyFleetHandover, cleanupFleetHandoverResources } from './fleet-hand
|
||||
const copy = value => value == null ? value : structuredClone(value)
|
||||
function fixture(fault = '') {
|
||||
const marker = 'TEST-HANDOVER-RUN', now = new Date(Date.now() - 60000).toISOString()
|
||||
const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', vehicleId: '11', driverId: '21', warehouseId: '92',
|
||||
const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', dispatchNo: 'PC-ONE', vehicleId: '11', driverId: '21', warehouseId: '92',
|
||||
stops: [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:ONE' }] }
|
||||
const originalTransport = { vehiclePlate: 'ORIGINAL', driverName: '原司机', driverPhone: '111' }
|
||||
const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT',
|
||||
recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
|
||||
const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
|
||||
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
|
||||
const seeds = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
|
||||
packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
|
||||
items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] }))
|
||||
if (fault === 'r5-layout') {
|
||||
Object.assign(dispatchDetails[0], { packageCount: 5, pickedPackageCount: 5, items: [
|
||||
Object.assign(seeds[0], { packageCount: 5, pickedPackageCount: 5, items: [
|
||||
{ id: 'ITEM-A1', warehousePalletId: 'PALLET-1', packageCount: 1 }, { id: 'ITEM-A2', warehousePalletId: 'PALLET-2', packageCount: 4 }] })
|
||||
Object.assign(dispatchDetails[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] })
|
||||
Object.assign(seeds[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] })
|
||||
}
|
||||
const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: row.packageCount }))
|
||||
const cargo = dispatchDetails.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: row.dispatchNo,
|
||||
const cargo = seeds.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: 'PC-ONE',
|
||||
dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount,
|
||||
destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' })))
|
||||
const totalPackages = cargo.reduce((n, row) => n + row.packages, 0)
|
||||
const counts = { itemCount: cargo.length, palletCount: new Set(cargo.map(r => r.warehousePalletId)).size,
|
||||
packageCount: totalPackages, pickedPackageCount: totalPackages, shippedPackageCount: totalPackages }
|
||||
const nativeItems = cargo.map(row => ({ dispatchItemId: row.dispatchItemId, warehousePalletId: row.warehousePalletId,
|
||||
customerId: row.customerId, destination: { key: row.destinationKey }, scheduledPackages: row.packages, pickedPackages: row.packages }))
|
||||
const nativeReferences = cargo.map((row,index) => ({ deliveryNo: `DL-${row.customerId}-${index}`, customerId: row.customerId, packageCount: row.packages }))
|
||||
const liveDispatches = [{ dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19', counts,
|
||||
documents: { deliveries: nativeReferences, remark: marker } }]
|
||||
const dispatchDetails = copy(liveDispatches)
|
||||
const pdaTask = { dispatchNo: 'PC-ONE', taskNo: 'PC-ONE', warehouseId: '92', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19',
|
||||
counts, pickingReconciled: true, items: nativeItems.map(row => ({ dispatchItemId: row.dispatchItemId, customerId: row.customerId,
|
||||
scheduledPackages: row.scheduledPackages, pickedPackages: row.pickedPackages })) }
|
||||
const pallets = [...new Set(cargo.map(row => row.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1 }))
|
||||
const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo,
|
||||
pallets } } }
|
||||
@@ -34,13 +45,13 @@ function fixture(fault = '') {
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
||||
const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
|
||||
const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) }
|
||||
const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId }))
|
||||
if (fault === 'binding-customer') bindings[0].customerId = 'OTHER'
|
||||
if (fault === 'dispatch-cargo') liveDispatches[0].items = [{ ...liveDispatches[0].items[0], id: 'OTHER-ITEM' }]
|
||||
const bindings = [{ dispatchNo: 'PC-ONE' }]
|
||||
if (fault === 'binding-customer') nativeItems[0].customerId = 'OTHER'
|
||||
if (fault === 'dispatch-cargo') nativeItems[0].dispatchItemId = 'OTHER-ITEM'
|
||||
const context = {
|
||||
context: { async checkpointFleetHandover() {} },
|
||||
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
|
||||
departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: dispatchDetails.reduce((n,row)=>n+row.packageCount,0), dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: row.packageCount })) } } },
|
||||
departure: { dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: now, shippedPackages: totalPackages } } } },
|
||||
assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) },
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0)
|
||||
@@ -55,14 +66,53 @@ function fixture(fault = '') {
|
||||
}
|
||||
if (path.includes('/handover-status')) {
|
||||
assert.equal(token, 'picker-secret')
|
||||
const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1)
|
||||
const dispatchNo = path.split('/').at(-2)
|
||||
const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport
|
||||
return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport,
|
||||
deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', packages: row.packages, terminalAt: null,
|
||||
deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${row.customerId}-${index}`, status: 'DELIVERING', packages: row.packages, terminalAt: null,
|
||||
transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } })),
|
||||
latestHandover: handover ? { handoverNo: handover.handoverNo } : null })
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1)))
|
||||
if (path.startsWith('/api/pda/v1/last-leg/dispatches/') && !path.endsWith('/handover-status')) {
|
||||
assert.equal(token, 'picker-secret')
|
||||
const task = copy(pdaTask)
|
||||
if (fault === 'pda-task-drift' && handover) task.counts.pickedPackageCount--
|
||||
return ok(task)
|
||||
}
|
||||
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') {
|
||||
assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, [])
|
||||
assert.equal(body.vehicle, null); assert.equal(body.driver, null)
|
||||
if (fault === 'unknown-probe-create') throw new Error('native empty dispatch result unknown')
|
||||
const index = probes.size + 1
|
||||
const next = { tripNo: `PROBE-${index}`, dispatchNo: `EMPTY-${index}`, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0',
|
||||
counts: { itemCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 }, documents: { remark: body.remark },
|
||||
warehouseId: body.warehouseId }
|
||||
probes.set(next.tripNo, next)
|
||||
const { tripNo: internalOnly, ...response } = next
|
||||
return ok(response)
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/dispatches/EMPTY-')) {
|
||||
const number = path.match(/dispatches\/(EMPTY-\d+)/)[1], probe = [...probes.values()].find(i => i.dispatchNo === number)
|
||||
if (path.endsWith('/items')) return ok({ dispatchNo: probe.dispatchNo, recordVersion: probe.recordVersion, items: fault === 'probe-has-cargo' ? [{}] : [] })
|
||||
if (path.endsWith('/cancel')) {
|
||||
assert.equal(body.recordVersion, probe.recordVersion)
|
||||
for (const [resource, owner] of resources) if (owner === probe.tripNo) resources.delete(resource)
|
||||
probe.status = 'CANCELLED'; return ok(probe)
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.driver.mode, 'MASTER'); assert.ok(body.idempotencyKey)
|
||||
if (resources.has(`VEHICLE:${body.vehicle.vehicleId}`)) return conflict('车辆已被车次占用')
|
||||
if (resources.has(`DRIVER:${body.driver.driverId}`)) return conflict('司机已被车次占用')
|
||||
resources.set(`VEHICLE:${body.vehicle.vehicleId}`, probe.tripNo); resources.set(`DRIVER:${body.driver.driverId}`, probe.tripNo)
|
||||
Object.assign(probe, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId, vehiclePlate: originalTransport.vehiclePlate, recordVersion: '1' })
|
||||
}
|
||||
const current = copy(probe)
|
||||
if (fault === 'probe-version-drift') current.recordVersion = '99'
|
||||
if (fault === 'probe-has-scan') current.counts.pickedPackageCount = 1
|
||||
return ok(current)
|
||||
}
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE/items') return ok({ dispatchNo: 'PC-ONE', recordVersion: '19', items: nativeItems })
|
||||
if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE') return ok(liveDispatches[0])
|
||||
if (path.endsWith('/master-data/vehicles') && method === 'POST') {
|
||||
assert.equal(body.plateNumber, body.plateNumber.toUpperCase(), 'request must use the formal uppercase vehicle plate')
|
||||
const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle)
|
||||
@@ -86,22 +136,13 @@ function fixture(fault = '') {
|
||||
if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' }
|
||||
return ok(fault === 'review-version' && path.endsWith('/review') ? { ...driver, recordVersion: '1' } : driver)
|
||||
}
|
||||
if (path.endsWith('/fleet/trips') && method === 'POST') {
|
||||
const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] })
|
||||
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) {
|
||||
const rows = [...probes.values()].filter(i => i.vehicleId).map(i => ({ ...i, status: 'PLANNED' }))
|
||||
return ok({ records: rows, total: rows.length, page: 1, size: 200 })
|
||||
}
|
||||
if (path.includes('/trips/PROBE-')) {
|
||||
const number = path.match(/\/trips\/([^/]+)/)[1], probe = probes.get(number)
|
||||
if (path.endsWith('/assignment')) {
|
||||
if (resources.has(`VEHICLE:${body.vehicleId}`)) return conflict('车辆已被车次占用')
|
||||
if (resources.has(`DRIVER:${body.driverId}`)) return conflict('司机已被车次占用')
|
||||
resources.set(`VEHICLE:${body.vehicleId}`, number); resources.set(`DRIVER:${body.driverId}`, number)
|
||||
Object.assign(probe, { vehicleId: body.vehicleId, driverId: body.driverId, recordVersion: '1' })
|
||||
}
|
||||
if (path.endsWith('/cancel')) {
|
||||
for (const [resource, owner] of resources) if (owner === number) resources.delete(resource)
|
||||
probe.status = 'CANCELLED'
|
||||
}
|
||||
return ok({ trip: probe, dispatches: [] })
|
||||
return ok({ trip: { ...probe, status: 'PLANNED' }, dispatches: [{ dispatchNo: probe.dispatchNo }] })
|
||||
}
|
||||
if (path.includes('/history?')) {
|
||||
const departure = copy(originalHistory)
|
||||
@@ -159,9 +200,12 @@ test('fresh real departure carries all customer cargo, preserves DEPART and fees
|
||||
assert.equal(result.browser.originalVehiclePlate, 'ORIGINAL'); assert.match(result.browser.replacementVehiclePlate, /^HO-/)
|
||||
assert.equal(f.proof.vehicleId, '12'); assert.equal(f.proof.driverId, '22'); assert.equal(f.proof.licenseFileId, 'TEST-LICENSE-FILE')
|
||||
assert.equal(result.replacement.certificate.synthetic, true); assert.ok(result.replacement.certificate.contentBase64)
|
||||
assert.equal(result.dispatchNos.length, 1); assert.equal(result.pdaTasksBefore.length, 1)
|
||||
assert.equal(f.proof.marker, result.replacement.vehiclePlate, 'current unique plate follows the resources for later native closure probes')
|
||||
assert.doesNotMatch(JSON.stringify(result), /picker-secret/)
|
||||
assert.ok(f.calls.filter(row => row.assertion).every(row => row.passed))
|
||||
assert.ok(!f.calls.some(row => row.path?.endsWith('/depart') || row.path?.endsWith('/complete') || row.path?.endsWith('/cancel')))
|
||||
assert.ok(!f.calls.some(row => row.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(row.path || '')))
|
||||
assert.equal(f.resources.get('VEHICLE:11'), 'PROBE-1'); assert.equal(f.resources.get('DRIVER:21'), 'PROBE-1')
|
||||
})
|
||||
|
||||
@@ -238,7 +282,8 @@ for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接
|
||||
'review-version': '接替司机已正式审核', 'preview-identity': '交接预览绑定原车次', 'preview-capacity': '交接预览容量', 'release-version': '原车辆已结束',
|
||||
'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
|
||||
'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用',
|
||||
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) {
|
||||
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆',
|
||||
'pda-task-drift': '交接不改变PDA原生整车版本及扫码归属', 'unknown-probe-create': 'native empty dispatch result unknown' })) {
|
||||
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), new RegExp(message))
|
||||
@@ -273,12 +318,22 @@ test('explicit closure cleanup cancels only test probes and disables newly creat
|
||||
assert.ok(!f.calls.some(row => row.method === 'DELETE'))
|
||||
})
|
||||
|
||||
for (const fault of ['probe-has-cargo', 'probe-version-drift', 'probe-has-scan']) test(`native empty-dispatch cleanup refuses unexpected state: ${fault}`, async () => {
|
||||
const f = fixture(fault)
|
||||
await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
|
||||
f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() }
|
||||
f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22')
|
||||
const before = f.calls.length
|
||||
await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /仅取消本轮未执行/)
|
||||
assert.ok(f.calls.slice(before).filter(row => row.method).every(row => row.method === 'GET'))
|
||||
})
|
||||
|
||||
test('formal driver response accepts empty profile with omitted or NULL optional fields', async () => {
|
||||
const f = fixture('driver-default-null')
|
||||
assert.equal((await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)).status, 'VERIFIED')
|
||||
})
|
||||
|
||||
test('r5 two dispatches, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => {
|
||||
test('r5 one cross-customer dispatch, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => {
|
||||
const f = fixture('r5-layout')
|
||||
const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
|
||||
assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaAfter.flatMap(row=>row.deliveries).length, 3)
|
||||
|
||||
@@ -25,7 +25,8 @@ function requireRun(context, tripProof) {
|
||||
const run = context.createdResources?.fleetAcceptance
|
||||
if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker
|
||||
|| run?.marker !== context.marker || run.tripNo !== tripProof.tripNo
|
||||
|| run.departure?.tripNo !== tripProof.tripNo || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt)
|
||||
|| run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.status !== 'ACTIVE'
|
||||
|| run.departure.stage !== 'DELIVERING' || !run.departure.result?.departedAt || !count(run.departure.result.shippedPackages))
|
||||
throw new Error('在途交接仅接受当前marker登记的原车次及真实DEPART结果,禁止借用旧样本')
|
||||
return run
|
||||
}
|
||||
@@ -60,10 +61,17 @@ function io(context, module, proof) {
|
||||
}
|
||||
return { check, request }
|
||||
}
|
||||
function dispatchFacts(row) {
|
||||
const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount',
|
||||
'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items']
|
||||
return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]]))
|
||||
function cargoFacts(values) {
|
||||
const totals = new Map()
|
||||
for (const row of values) {
|
||||
const facts = { itemId: text(row.dispatchItemId), customerId: text(row.customerId), palletId: text(row.warehousePalletId),
|
||||
destinationKey: row.destination?.key ?? row.destinationKey }
|
||||
if (!facts.itemId || !facts.customerId || !facts.palletId || !facts.destinationKey) throw new Error('原生货物缺少真实明细客户或目的地')
|
||||
const identity = stable(facts), packages = Number(row.scheduledPackages ?? row.packages)
|
||||
if (!count(packages)) throw new Error('原生货物缺少有效真实箱数')
|
||||
totals.set(identity, { ...facts, packages: (totals.get(identity)?.packages || 0) + packages })
|
||||
}
|
||||
return [...totals.values()].sort((a, b) => stable(a).localeCompare(stable(b)))
|
||||
}
|
||||
function feeFacts(rows) {
|
||||
if (!Array.isArray(rows)) throw new Error('费用来源不是正式列表')
|
||||
@@ -85,10 +93,9 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length)
|
||||
throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑')
|
||||
const numbers = dispatchDetails.map(row => row.dispatchNo)
|
||||
if (new Set(numbers).size !== numbers.length || numbers.some(number => !number)
|
||||
|| !same([...numbers].sort(), run.departure.dispatches?.map(row => row.dispatchNo).sort())
|
||||
|| !dispatchDetails.every(row => count(row.packageCount))
|
||||
|| Number(run.departure.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.packageCount), 0))
|
||||
if (numbers.length !== 1 || numbers[0] !== tripProof.dispatchNo || numbers[0] !== run.departure.dispatchNo
|
||||
|| !dispatchDetails.every(row => count(row.counts?.packageCount))
|
||||
|| Number(run.departure.result.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.counts.packageCount), 0))
|
||||
throw new Error('交接输入排车清单及箱数未覆盖本轮真实发车')
|
||||
const proof = tripProof.handover = { marker: context.marker, fixtureMarker: tripProof.marker, status: 'CHECKING', requests: [], probes: [],
|
||||
dispatchNos: [...numbers], stockEvidence: 'CALLER_FULL_CHAIN_INVENTORY_AUDIT_REQUIRED' }
|
||||
@@ -96,7 +103,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
try {
|
||||
const detail = await request('GET', base, undefined, '读取真实在途车次及分配资源')
|
||||
check('真实车次属于本轮且已经发车', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'IN_TRANSIT'
|
||||
&& detail.trip.departedAt === run.departure.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId)
|
||||
&& detail.trip.departedAt === run.departure.result.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId)
|
||||
&& text(detail.trip.driverId) === text(tripProof.driverId) && text(detail.trip.warehouseId) === text(tripProof.warehouseId)
|
||||
&& validVersion(detail.trip.recordVersion) && same(detail.dispatches?.map(row => row.dispatchNo).sort(), [...numbers].sort()))
|
||||
const initialHistory = await request('GET', `${base}/history?page=1&size=200`, undefined, '读取不可变原DEPART证据')
|
||||
@@ -109,26 +116,36 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
&& text(original.trip.vehicleId) === text(detail.trip.vehicleId) && text(original.trip.driverId) === text(detail.trip.driverId)
|
||||
&& original.confirmedLoad?.cargo?.length > 0 && original.confirmedLoad?.pallets?.length > 0)
|
||||
proof.previous = { vehicleId: text(detail.trip.vehicleId), driverId: text(detail.trip.driverId), ...transport(detail.trip) }
|
||||
proof.dispatchBefore = []; proof.pdaBefore = []; proof.feesBefore = []
|
||||
proof.dispatchBefore = []; proof.pdaBefore = []; proof.pdaTasksBefore = []; proof.feesBefore = []
|
||||
for (const expected of dispatchDetails) {
|
||||
const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库')
|
||||
const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo)
|
||||
const items = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '读取原生主单逐客户真实货物')
|
||||
const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo)
|
||||
const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId),
|
||||
packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId))
|
||||
check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED'
|
||||
&& Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount)
|
||||
&& binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId))
|
||||
&& same(items(row.items || []), items(cargo)) && text(row.overseasWarehouseId) === text(tripProof.warehouseId))
|
||||
proof.dispatchBefore.push(dispatchFacts(row))
|
||||
check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'ACTIVE' && row.stage === 'DELIVERING'
|
||||
&& Number(row.counts?.shippedPackageCount) === Number(expected.counts.packageCount) && Number(row.counts?.pickedPackageCount) === Number(expected.counts.packageCount)
|
||||
&& items.dispatchNo === row.dispatchNo && text(items.recordVersion) === text(row.recordVersion) && cargo.length > 0
|
||||
&& same(cargoFacts(items.items || []), cargoFacts(cargo)) && row.documents?.deliveries?.length > 0)
|
||||
proof.dispatchBefore.push({ dispatchNo: row.dispatchNo, detail: row, items })
|
||||
const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}`, undefined, 'PDA读取原生整车任务和客户来源', { token: pickerToken })
|
||||
check('PDA原生作业与整车真实客户货物核平', task.dispatchNo === row.dispatchNo && task.status === 'ACTIVE' && task.stage === 'DELIVERING'
|
||||
&& text(task.warehouseId) === text(tripProof.warehouseId) && text(task.recordVersion) === text(row.recordVersion)
|
||||
&& same(task.counts, row.counts) && task.pickingReconciled === true
|
||||
&& same(task.items?.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages }))
|
||||
.sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId)),
|
||||
items.items.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages }))
|
||||
.sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId))))
|
||||
proof.pdaTasksBefore.push(task)
|
||||
const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken })
|
||||
check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo
|
||||
&& pda.latestHandover == null && same(transport(pda.originalDeparture), transport(original.trip)) && pda.deliveries?.length > 0
|
||||
&& pda.deliveries.every(item => !item.terminalAt && item.status === 'WAIT_DELIVERY' && same(transport(item.transport), transport(detail.trip)))
|
||||
&& pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.packageCount))
|
||||
&& pda.deliveries.every(item => !item.terminalAt && item.status === 'DELIVERING' && same(transport(item.transport), transport(detail.trip)))
|
||||
&& pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.counts.packageCount)
|
||||
&& same(pda.deliveries.map(i => i.deliveryNo).sort(), row.documents.deliveries.map(i => i.deliveryNo).sort()))
|
||||
proof.pdaBefore.push(pda)
|
||||
for (const delivery of pda.deliveries) {
|
||||
const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(binding.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}`
|
||||
const reference = row.documents.deliveries.find(i => i.deliveryNo === delivery.deliveryNo)
|
||||
check('派送费用严格沿真实主单逐客户单据归属', reference?.customerId && cargo.some(i => text(i.customerId) === text(reference.customerId)))
|
||||
const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(reference.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}`
|
||||
proof.feesBefore.push({ path, facts: feeFacts(await request('GET', path, undefined, '冻结本次派送费用来源')) })
|
||||
}
|
||||
}
|
||||
@@ -214,8 +231,12 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
await request('POST', `${base}/handovers`, proof.staleRequest, '陈旧版本交接必须拒绝', { conflict: /车次.*(变化|修改)|版本/ })
|
||||
check('陈旧命令没有改变车次版本或资源', same(await request('GET', base, undefined, '拒绝后核对车次零变更'), after))
|
||||
const firstProbe = await createProbe(1)
|
||||
const assignment = { recordVersion: firstProbe.recordVersion, vehicleId: proof.previous.vehicleId, driverId: proof.previous.driverId, reason: '本轮旧资源占用核对' }
|
||||
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ })
|
||||
const assignment = { recordVersion: firstProbe.recordVersion, idempotencyKey: key(),
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.previous.vehicleId },
|
||||
driver: { mode: 'MASTER', driverId: proof.previous.driverId, evidence: { expectedTaskEndAt: detail.trip.expectedDriverEndAt } },
|
||||
plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, remark: `${context.marker}本轮旧资源占用核对` }
|
||||
firstProbe.assignmentRequest = clone(assignment)
|
||||
await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ })
|
||||
const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release`
|
||||
proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() }
|
||||
const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务')
|
||||
@@ -225,7 +246,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
&& vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey
|
||||
&& vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE'
|
||||
&& vehicleReleased.driverRelease == null)
|
||||
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
|
||||
await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
|
||||
proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() }
|
||||
const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务')
|
||||
check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo
|
||||
@@ -234,20 +255,53 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
&& Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt)
|
||||
&& released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey
|
||||
&& released.driverRelease?.location === proof.driverReleaseRequest.location)
|
||||
const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划')
|
||||
firstProbe.recordVersion = assigned.trip.recordVersion
|
||||
check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId)
|
||||
const assigned = await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '将已释放原资源分配给本轮无货计划')
|
||||
check('原资源真正可重新分配', assigned.dispatchNo === firstProbe.dispatchNo && assigned.status === 'ACTIVE' && assigned.stage === 'BEFORE_LOADING'
|
||||
&& BigInt(text(assigned.recordVersion)) > BigInt(text(firstProbe.recordVersion)))
|
||||
firstProbe.recordVersion = assigned.recordVersion
|
||||
const candidateRows = []; let candidateTotal
|
||||
for (let page = 1; ; page++) {
|
||||
const candidates = await request('GET', `${FLEET}/trips?${new URLSearchParams({ keyword: proof.previous.vehiclePlate,
|
||||
warehouseId: text(tripProof.warehouseId), page: text(page), size: '200' })}`, undefined, '定位本轮空主单自动Fleet资源记录')
|
||||
check('资源候选分页完整且稳定', Array.isArray(candidates.records) && Number(candidates.page) === page && Number(candidates.size) === 200
|
||||
&& Number.isSafeInteger(Number(candidates.total)) && Number(candidates.total) >= 0
|
||||
&& (candidateTotal === undefined || candidateTotal === Number(candidates.total)))
|
||||
candidateTotal ??= Number(candidates.total)
|
||||
check('资源候选未缺页', candidates.records.length > 0 || candidateRows.length === candidateTotal)
|
||||
candidateRows.push(...candidates.records); if (candidateRows.length >= candidateTotal) break
|
||||
}
|
||||
check('资源候选总数和业务号无重复', candidateRows.length === candidateTotal && new Set(candidateRows.map(i => i.tripNo)).size === candidateRows.length)
|
||||
const matches = []
|
||||
for (const row of candidateRows.filter(row => text(row.vehicleId) === proof.previous.vehicleId && text(row.driverId) === proof.previous.driverId
|
||||
&& row.vehiclePlate === proof.previous.vehiclePlate && text(row.warehouseId) === text(tripProof.warehouseId))) {
|
||||
const candidate = await request('GET', pathFor(row.tripNo), undefined, '核对新资源记录与原生空单唯一绑定')
|
||||
if (candidate.dispatches?.some(i => i.dispatchNo === firstProbe.dispatchNo)) {
|
||||
check('新资源记录只能绑定本轮唯一空主单', candidate.trip?.tripNo === row.tripNo && candidate.dispatches.length === 1
|
||||
&& text(candidate.trip.vehicleId) === proof.previous.vehicleId && text(candidate.trip.driverId) === proof.previous.driverId)
|
||||
matches.push(candidate)
|
||||
}
|
||||
}
|
||||
check('原资源真正可重新分配且唯一定位', matches.length === 1 && matches[0].trip.tripNo !== tripProof.tripNo)
|
||||
firstProbe.tripNo = matches[0].trip.tripNo
|
||||
const persistedReleases = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化资源结束记录')
|
||||
const releaseSnapshot = persistedReleases.records?.find(row => row.handoverNo === proof.handoverNo)
|
||||
check('原车辆结束重放不改记录', same(await request('POST', releaseBase, proof.vehicleReleaseRequest, '新计划占用后重放旧车辆结束请求'), releaseSnapshot))
|
||||
check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot))
|
||||
const secondProbe = await createProbe(2)
|
||||
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
|
||||
const secondAssignment = { ...assignment, idempotencyKey: key(), recordVersion: secondProbe.recordVersion }
|
||||
await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, secondAssignment, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
|
||||
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` }
|
||||
const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆')
|
||||
check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id)
|
||||
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })
|
||||
for (const expected of proof.dispatchBefore) check('交接不改变原排车出库和货物归属', same(dispatchFacts(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对排车原始出库事实')), expected))
|
||||
await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, { ...secondAssignment, idempotencyKey: key(), vehicle: { mode: 'MASTER', vehicleId: spare.id } }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })
|
||||
for (const expected of proof.dispatchBefore) {
|
||||
check('交接不改变原排车出库和货物归属', same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对原生排车出库事实'), expected.detail)
|
||||
&& same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '核对原生排车逐客户货物事实'), expected.items))
|
||||
}
|
||||
for (const previous of proof.pdaTasksBefore) {
|
||||
const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}`, undefined, 'PDA回查原生整车版本及扫码归属未变化', { token: pickerToken })
|
||||
check('交接不改变PDA原生整车版本及扫码归属', same(current, previous))
|
||||
}
|
||||
proof.pdaAfter = []
|
||||
for (const previous of proof.pdaBefore) {
|
||||
const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}/handover-status`, undefined, 'PDA只读核对接替资源与原始发车', { token: pickerToken })
|
||||
@@ -270,16 +324,19 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
|
||||
proof.status = 'VERIFIED'
|
||||
// Caller finishFleetTrip must finish the currently assigned resources, not the original pair.
|
||||
tripProof.vehicleId = proof.replacement.vehicleId; tripProof.driverId = proof.replacement.driverId; tripProof.licenseFileId = proof.replacement.licenseFileId
|
||||
tripProof.marker = proof.replacement.vehiclePlate
|
||||
return proof
|
||||
} catch (error) { proof.status = 'FAILED'; proof.failure = { message: error.message, requestCount: proof.requests.length }; throw error }
|
||||
|
||||
async function createProbe(index) {
|
||||
const saved = { marker: context.marker, index }; proof.probes.push(saved)
|
||||
const value = await request('POST', `${FLEET}/trips`, { warehouseId: tripProof.warehouseId, idempotencyKey: key(),
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
|
||||
stops: tripProof.stops, remark: `${context.marker}在途交接资源验证无货计划${index}` }, '创建本轮无货资源核对计划')
|
||||
check('核对计划不承载货物且尚未发车', value?.trip?.tripNo && value.trip.status === 'PLANNED' && value.dispatches?.length === 0 && validVersion(value.trip.recordVersion))
|
||||
saved.tripNo = value.trip.tripNo; saved.recordVersion = value.trip.recordVersion; return saved
|
||||
saved.createRequest = { warehouseId: text(tripProof.warehouseId), idempotencyKey: key(), vehicle: null, driver: null,
|
||||
plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, cargo: [], batchAllocations: [],
|
||||
remark: `${context.marker}在途交接资源验证原生空排车${index}` }
|
||||
const value = await request('POST', DISPATCHES, saved.createRequest, '创建本轮无货资源核对计划')
|
||||
check('核对计划不承载货物且尚未发车', value?.dispatchNo && value.status === 'ACTIVE' && value.stage === 'BEFORE_LOADING'
|
||||
&& value.counts?.packageCount === 0 && value.counts?.pickedPackageCount === 0 && value.counts?.shippedPackageCount === 0 && validVersion(value.recordVersion))
|
||||
saved.dispatchNo = value.dispatchNo; saved.recordVersion = value.recordVersion; return saved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,10 +352,15 @@ export async function cleanupFleetHandoverResources(context, module, tripProof)
|
||||
check('整车结案且接替车辆司机均已释放', current.trip?.status === 'COMPLETED' && current.trip.vehicleRelease && current.trip.driverRelease
|
||||
&& text(current.trip.vehicleId) === proof.replacement.vehicleId && text(current.trip.driverId) === proof.replacement.driverId)
|
||||
for (const probe of [...proof.probes].reverse()) {
|
||||
const path = pathFor(probe.tripNo), detail = await request('GET', path, undefined, '核对仅本轮无货资源计划')
|
||||
check('仅取消本轮未执行无货计划', detail.trip?.tripNo === probe.tripNo && detail.trip.status === 'PLANNED' && detail.trip.remark?.includes(context.marker) && detail.dispatches?.length === 0)
|
||||
const cancelled = await request('POST', `${path}/cancel`, { recordVersion: detail.trip.recordVersion, reason: '本轮交接验证完成,取消无货核对计划' }, '取消本轮资源核对计划')
|
||||
check('无货计划已取消', cancelled.trip?.status === 'CANCELLED')
|
||||
const path = `${DISPATCHES}/${encodeURIComponent(probe.dispatchNo)}`, detail = await request('GET', path, undefined, '核对仅本轮原生空排车')
|
||||
const items = await request('GET', `${path}/items`, undefined, '核对本轮空排车零实际货物')
|
||||
check('仅取消本轮未执行无货计划', detail.dispatchNo === probe.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING'
|
||||
&& detail.documents?.remark?.includes(context.marker) && text(detail.recordVersion) === text(probe.recordVersion)
|
||||
&& detail.counts?.itemCount === 0 && detail.counts?.packageCount === 0 && detail.counts?.pickedPackageCount === 0 && detail.counts?.shippedPackageCount === 0
|
||||
&& items.dispatchNo === probe.dispatchNo && text(items.recordVersion) === text(detail.recordVersion) && items.items?.length === 0)
|
||||
probe.cancelRequest = { recordVersion: detail.recordVersion, idempotencyKey: key(), reason: '本轮交接验证完成,取消原生空排车并保留历史' }
|
||||
const cancelled = await request('POST', `${path}/cancel`, probe.cancelRequest, '取消本轮资源核对计划')
|
||||
check('无货计划已取消', cancelled.dispatchNo === probe.dispatchNo && cancelled.status === 'CANCELLED')
|
||||
}
|
||||
for (const vehicleId of [proof.replacement.vehicleId, proof.spareVehicleId]) {
|
||||
const vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(vehicleId)}`, undefined, '读取本轮接替或隔离车辆版本')
|
||||
|
||||
+208
-259
@@ -6,180 +6,223 @@ import { tmpdir } from 'node:os'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit } from './fleet-return-redispatch-acceptance.mjs'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg', DISPATCH = `${BASE}/dispatches`, PDA = '/api/pda/v1/last-leg/dispatches', CUSTOMER = '900719925474099301', WAREHOUSE = '92'
|
||||
const copy = value => structuredClone(value)
|
||||
function fixture(fault = '') {
|
||||
const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = []
|
||||
let returned = false, originalSigned = false, newSigned = false, departed = false
|
||||
const trip = { tripNo: 'TR-ORIGINAL', dispatchNo: 'DP-ORIGINAL' }, requests = [], checkpoints = [], ledger = new Map(), controls = {}
|
||||
const masterIds = { A: '9007199254740993', B: '9007199254740995' }
|
||||
const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`,
|
||||
customerId: 'C', overseasWarehouseId: 'W', inboundOrderId: fault === 'no-inbound' ? null : String(index + 10), inboundCode: `IN-${name}`,
|
||||
deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] }))
|
||||
const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3,
|
||||
subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY'
|
||||
: originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' })
|
||||
const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null }
|
||||
: { deliveryNo: 'DL-ORIGINAL', dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: 'DELIVERING', packageCount: 7,
|
||||
signedPackages: originalSigned ? 5 : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : 'original-signature' : null }
|
||||
const envelope = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request-id', traceId: 'trace-id' })
|
||||
const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: 'C', warehouseId: 'W', tripNo: trip.tripNo, departure: { status: 'IN_TRANSIT', dispatches: [{ dispatchNo: 'DP-ORIGINAL' }] } } },
|
||||
assert() {}, async checkpoint() { checkpoints.push(structuredClone(trip.returnRedispatch)) },
|
||||
async requestMultipart(method, path, file) { requests.push({ method, path, file: file.name }); return envelope({ fileId: '99' }) },
|
||||
async request(method, path, body, token) {
|
||||
requests.push({ method, path, body: structuredClone(body), token })
|
||||
if (path.startsWith('/api/platform/inbounds?')) {
|
||||
const code = new URLSearchParams(path.split('?')[1]).get('inboundCode'), name = code.slice(-1)
|
||||
const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned
|
||||
return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] })
|
||||
}
|
||||
if (path.includes('/orders/')) {
|
||||
const masterId = path.split('/orders/')[1].split('?')[0]
|
||||
const name = Object.keys(masterIds).find(name => masterIds[name] === masterId)
|
||||
assert.ok(name, `unknown master ${masterId}`)
|
||||
return envelope({ order: tail(name) })
|
||||
}
|
||||
if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations)
|
||||
if (path.includes('/mark-return')) {
|
||||
assert.equal(body.lastMileOrderId, masterIds.A)
|
||||
assert.equal(Object.hasOwn(body, 'inboundOrderId'), false)
|
||||
assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }])
|
||||
return envelope({ exceptionNo: 'EX-NEW', version: 0 })
|
||||
}
|
||||
if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 })
|
||||
if (path.includes('/exception-orders/') && path.includes('/complete')) {
|
||||
const replayed = returned; returned = true
|
||||
if (fault === 'unknown-return') throw Error('connection lost after commit')
|
||||
return envelope({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', replayed })
|
||||
}
|
||||
if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') }
|
||||
if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode })
|
||||
if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] })
|
||||
if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: masterIds.A, packageCount: fault === 'wrong-candidate' ? 4 : 2 }])
|
||||
if (path.endsWith('/drafts/containers')) return envelope({ id: '400' })
|
||||
if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) }
|
||||
if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) }
|
||||
if (path.endsWith('/fleet/trips/TR-NEW')) return envelope({ trip: { vehiclePlate: 'NEW', driverName: 'driver', driverPhone: 'phone' } })
|
||||
if (path.endsWith('/transfer-delivery')) return envelope({ deliveries: [{ deliveryNo: 'DL-NEW' }] })
|
||||
if (path.endsWith('/depart')) { assert.equal(token, 'private-pda-token'); const replayed = departed; departed = true; return envelope({ tripNo: 'TR-NEW', shippedPackages: 2, replayed }) }
|
||||
if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/delivery-orders/')[1].split('?')[0]) })
|
||||
throw Error(`Unexpected ${method} ${path}`)
|
||||
} }
|
||||
const dependencies = {
|
||||
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) }
|
||||
const relations = ['A', 'B'].map((name, n) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`, customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE,
|
||||
inboundOrderId: fault === 'no-inbound' ? null : `${n + 10}`, inboundCode: `IN-${name}`, deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL',
|
||||
returnSources: [{ outboundPalletItemId: `${n + 20}`, inventoryPositionId: `${n + 30}`, maxReturnPackages: n ? 3 : 4, returnCartonId: null }] }))
|
||||
let returned = false, originalSigned = false, newSigned = false, departed = false, picked = false, recordVersion = 0, stage = 'BEFORE_LOADING', review = false, arranged = false, created = false, nextCreated = false, nextCancelled = false
|
||||
let vehiclePlate, vehicleOwner = 'TR-NEW', driverOwner = 'TR-NEW', closed = false, vehicleReleased = false, driverReleased = false, tripVersion = 0
|
||||
const rootDone = name => name === 'A' ? newSigned || fault === 'root-premature-A' && originalSigned : originalSigned
|
||||
const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG',
|
||||
sourceInboundId: name === 'A' ? '801' : '802', sourceOrderId: name === 'A' ? '701' : '702', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3,
|
||||
subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' })
|
||||
const signedQuantity = (kind, fallback) => controls[kind]?.override ? controls[kind].value : fallback
|
||||
const order = name => name === 'DL-NEW' ? { deliveryNo: name, customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, dispatchNo: 'DP-NEW', outboundNo: 'OUT-NEW', status: newSigned ? 'DELIVERED' : departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2,
|
||||
signedPackages: newSigned ? signedQuantity('newQuantity', 2) : null, signedAt: newSigned ? '2026-10-19T04:00:00Z' : null, version: 0 }
|
||||
: { deliveryNo: 'DL-ORIGINAL', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: originalSigned ? 'DELIVERED' : controls.waitDelivery ? 'WAIT_DELIVERY' : 'DELIVERING', packageCount: 7,
|
||||
signedPackages: originalSigned ? signedQuantity('originalQuantity', 5) : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : '2026-10-19T02:00:00Z' : null, version: controls.deliveryVersion ?? 0 }
|
||||
const counts = empty => ({ itemCount: empty ? 0 : 1, palletCount: empty ? 0 : 1, packageCount: empty ? 0 : 2, pickedPackageCount: empty ? 0 : picked ? 2 : 0, shippedPackageCount: empty ? 0 : departed ? 2 : 0 })
|
||||
const destination = { key: 'FBA:ONT8', type: 'FBA', warehouseCode: 'ONT8' }
|
||||
const stops = [{ index: 0, name: 'Actual Origin', destinationKey: 'ORIGIN:92' }, { index: 1, name: 'ONT8', destinationKey: 'FBA:ONT8' }]
|
||||
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,
|
||||
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 })
|
||||
const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 })
|
||||
const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false })
|
||||
const tripDetail = next => ({ trip: { tripNo: next ? 'TR-NEXT' : 'TR-NEW', warehouseId: WAREHOUSE, vehicleId: '601', driverId: '701', vehiclePlate, tripPurpose: 'DELIVERY', recordVersion: tripVersion, status: closed && !next ? 'COMPLETED' : 'PLANNED',
|
||||
completedAt: closed && !next ? '2026-10-19T05:00:00Z' : null, vehicleRelease: vehicleReleased && !next ? { endLocation: 'ONT8' } : null, driverRelease: driverReleased && !next ? { endLocation: 'ONT8' } : null, stops }, dispatches: [{ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW' }] })
|
||||
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' }] } } } },
|
||||
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) {
|
||||
requests.push({ method, path, file: file.name })
|
||||
if (path.includes('/fleet/drivers/701/license')) return envelope({ licenseFileId: '801', recordVersion: 1 })
|
||||
return envelope({ fileId: '99' })
|
||||
},
|
||||
async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true },
|
||||
async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
|
||||
async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } },
|
||||
}
|
||||
const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] }
|
||||
return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds }
|
||||
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 (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' }] }) }
|
||||
if (path.startsWith('/api/platform/v1/orders/first-leg/')) {
|
||||
const orderId = path.split('/').at(-1), name = orderId === '701' ? 'A' : 'B', done = rootDone(name)
|
||||
if (['shared-root', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending'].includes(fault)) {
|
||||
const isRoot = orderId === '700', complete = isRoot ? newSigned || fault === 'root-premature-with-pending' : done || name === 'A' && fault === 'child-premature-A'
|
||||
return envelope({ order: { id: orderId, orderNo: isRoot ? 'FIRST-ROOT' : `FIRST-${name}`, customerId: CUSTOMER, tenantId: CUSTOMER, orderRole: isRoot ? 'MASTER' : 'CHILD',
|
||||
rootOrderId: fault === 'borrowed-child' && name === 'B' && !isRoot ? '999' : '700', parentOrderId: '700', status: complete ? 'COMPLETED' : 'PROCESSING' },
|
||||
statusHistory: isRoot && complete ? [{ action: 'OVERSEAS_DELIVERED' }, { action: 'FULFILLMENT_COMPLETED' }] : [] })
|
||||
}
|
||||
return envelope({ order: { id: orderId, orderNo: `FIRST-${name}`, customerId: CUSTOMER, tenantId: CUSTOMER, orderRole: 'MASTER', status: done ? 'COMPLETED' : 'PROCESSING' }, statusHistory: done ? [{ action: 'OVERSEAS_DELIVERED' }, { action: 'FULFILLMENT_COMPLETED' }] : [] })
|
||||
}
|
||||
if (path.endsWith('/fulfillment-tree')) { const orderId = path.split('/').at(-2); return envelope({ parentOrderId: orderId, parentOrderNo: orderId === '700' ? 'FIRST-ROOT' : orderId === '701' ? 'FIRST-A' : 'FIRST-B',
|
||||
children: orderId === '700' ? [{ orderId: '701', orderNo: 'FIRST-A' }, { orderId: '702', orderNo: 'FIRST-B' }] : [{ orderId, orderNo: orderId === '701' ? 'FIRST-A' : 'FIRST-B' }] }) }
|
||||
if (path.startsWith(`${BASE}/orders/`)) { const name = Object.keys(masterIds).find(name => masterIds[name] === path.split('/').at(-1)); assert.ok(name); return envelope({ order: tail(name) }) }
|
||||
if (path.endsWith('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations)
|
||||
if (path.endsWith('/mark-return')) { assert.equal(body.lastMileOrderId, masterIds.A); assert.equal(Object.hasOwn(body, 'inboundOrderId'), false); assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }]); return remember({ exceptionNo: 'EX-NEW', version: 0, replayed: false }) }
|
||||
if (path.endsWith('/start-processing')) return remember({ status: 'PROCESSING', version: 1 })
|
||||
if (path.includes('/exception-orders/') && path.endsWith('/complete')) { returned = true; if (fault === 'unknown-return') throw Error('connection lost after commit'); return remember({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', version: 2, replayed: false }) }
|
||||
if (path.endsWith('/exception-orders/EX-NEW')) return envelope({ order: { exceptionNo: 'EX-NEW', customerId: CUSTOMER, outboundNo: 'OUT-ORIGINAL' }, returnReceipts: [{ receiptNo: 'RSR-300', receivedPackages: 2 }] })
|
||||
if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') }
|
||||
if (path.endsWith('/pallets/100')) return envelope({ warehouseId: WAREHOUSE, locationId: '200', palletCode: context.palletCode })
|
||||
if (path.endsWith('/locations')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] })
|
||||
if (path.endsWith('/inventory/balances')) return envelope({ records: returned && !departed ? [{ id: '401', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE }] : [], total: returned && !departed ? 1 : 0 })
|
||||
if (path.includes('/inventory/balances/') && path.endsWith('/positions')) return envelope([{ id: '201', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletId: '100', palletNo: context.palletCode, availablePackages: picked ? 0 : 2, pickedPackages: picked ? 2 : 0 }])
|
||||
if (path.endsWith('/inventory/ledger')) { const rows = stockLedger(); if (fault === 'duplicate-ledger' && returned) rows.push({ ...rows[0], id: '902' }); return envelope({ records: rows, total: rows.length }) }
|
||||
if (path === `${DISPATCH}/candidates`) { assert.equal(params.get('warehouseId'), WAREHOUSE); return envelope({ complete: true, snapshotAt: '2026-10-19', candidates: [{ selection: { sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', planPalletId: null, relabelNo: null, warehousePalletId: '100', sourceVersion: 'a'.repeat(64) },
|
||||
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('/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 }) }
|
||||
if (path.endsWith('/fleet/trips/TR-NEW/completion')) return envelope({ canComplete: true, outcomes: [{}], recordVersion: tripVersion })
|
||||
if (path.endsWith('/fleet/trips/TR-NEW/complete')) { closed = true; tripVersion++; return remember(tripDetail(false)) }
|
||||
if (path.endsWith('/resource-occupancy')) return envelope([{ resourceId: params.get('resourceIds'), occupiedTripNo: params.get('kind') === 'VEHICLE' ? vehicleOwner : driverOwner }])
|
||||
if (path.endsWith('/resources/release')) {
|
||||
const prior = ledger.get(ledgerKey)
|
||||
if (!prior) { if (body.resourceKind === 'VEHICLE') { vehicleReleased = true; vehicleOwner = null } else { driverReleased = true; driverOwner = null }; tripVersion++; ledger.set(ledgerKey, { body: copy(body), data: null }) }
|
||||
else assert.deepEqual(prior.body, body)
|
||||
return envelope(tripDetail(false))
|
||||
}
|
||||
if (path.endsWith('/fleet/trips/TR-NEW')) return envelope(tripDetail(false))
|
||||
if (path.endsWith('/fleet/trips/TR-NEXT')) return envelope(tripDetail(true))
|
||||
if (path.includes('/delivery-orders/') && path.endsWith('/documents')) {
|
||||
const number = path.split('/delivery-orders/')[1].split('/')[0]
|
||||
if (method === 'GET') return envelope({ version: 0, categories: [] })
|
||||
if (number === 'DL-NEW') newSigned = true; else originalSigned = true
|
||||
return remember({ version: 1, categories: [{ category: 'RECEIPT', status: 'UPLOADED', fileCount: 1 }], replayed: false })
|
||||
}
|
||||
if (path.endsWith('/status') && path.includes('/delivery-orders/')) { assert.equal(body.expectedVersion, controls.deliveryVersion ?? 0); controls.waitDelivery = false; return envelope({ deliveryStatus: 'DELIVERING' }) }
|
||||
if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/').at(-1)), operations: [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] })
|
||||
const next = normalized.startsWith(`${DISPATCH}/DP-NEXT`)
|
||||
if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] })
|
||||
if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops })
|
||||
if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] })
|
||||
if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2,
|
||||
...(fault === 'missing-measurements' ? {} : { palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, deliveryIndex: 1, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] })
|
||||
if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] })
|
||||
if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next))
|
||||
assert.ok(created, `${method} ${raw}`)
|
||||
if (normalized.endsWith('/cancel')) { nextCancelled = true; vehicleOwner = null; driverOwner = null; return remember({ ...command('CANCEL', {}, true), status: 'CANCELLED' }) }
|
||||
if (method === 'PUT' && normalized.endsWith('/DP-NEW')) { if (picked) return envelope(null, 'A0409', '已扫码不能编辑'); assert.equal(body.recordVersion, recordVersion); recordVersion++; return remember(command('EDIT')) }
|
||||
if (normalized.endsWith('/pick') && body.recordVersion !== recordVersion) return envelope(null, 'A0409', '公共版本已变化')
|
||||
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()] })) }
|
||||
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) } } }
|
||||
const input = { customerId: CUSTOMER, warehouseId: WAREHOUSE, pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] }
|
||||
return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds, controls }
|
||||
}
|
||||
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
|
||||
|
||||
for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`unknown or invalid original signed quantity ${signedPackages} stops before redispatch`, async () => {
|
||||
const f = fixture(), save = f.dependencies.saveReceiptAndComplete
|
||||
f.dependencies.saveReceiptAndComplete = async (...args) => {
|
||||
const result = await save(...args)
|
||||
result.order.signedPackages = signedPackages
|
||||
return result
|
||||
}
|
||||
f.dependencies.prepareFleetTrip = () => assert.fail('invalid signed quantity must stop before preparing redispatch')
|
||||
await assert.rejects(run(f), /stored quantity/)
|
||||
assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0)
|
||||
})
|
||||
|
||||
for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requires actual numeric two signed boxes: ${signedPackages}`, async () => {
|
||||
const f = fixture(), save = f.dependencies.saveReceiptAndComplete
|
||||
f.dependencies.saveReceiptAndComplete = async (...args) => {
|
||||
const result = await save(...args)
|
||||
if (args[1].deliveryNo === 'DL-NEW') result.order.signedPackages = signedPackages
|
||||
return result
|
||||
}
|
||||
f.dependencies.finishFleetTrip = () => assert.fail('invalid new signature must not release trip resources')
|
||||
await assert.rejects(run(f), /two boxes actually signed/)
|
||||
})
|
||||
|
||||
test('mark-return uses the selected master ID even without an ordinary inbound relation', async () => {
|
||||
const f = fixture('no-inbound')
|
||||
assert.ok(f.relations.every(row => row.inboundOrderId === null))
|
||||
assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
|
||||
const writes = f.requests.filter(row => row.path.includes('/mark-return'))
|
||||
assert.equal(writes.length, 1)
|
||||
assert.equal(writes[0].body.lastMileOrderId, f.masterIds.A)
|
||||
assert.equal(Object.hasOwn(writes[0].body, 'inboundOrderId'), false)
|
||||
})
|
||||
|
||||
for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) {
|
||||
test(`return source identity ${fault} stops before mark-return and stock writes`, async () => {
|
||||
const f = fixture(), request = f.context.request.bind(f.context)
|
||||
if (fault === 'missing-master') delete f.relations[0].lastMileOrderId
|
||||
if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER'
|
||||
if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER'
|
||||
if (fault === 'foreign-customer') f.relations[0].customerId = 'OTHER'
|
||||
if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = 'OTHER'
|
||||
if (fault === 'borrowed-master') f.context.request = async (...args) => {
|
||||
const response = await request(...args)
|
||||
if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B
|
||||
return response
|
||||
}
|
||||
await assert.rejects(run(f), /real master ID|match this candidate/)
|
||||
assert.ok(f.requests.every(row => row.method === 'GET'))
|
||||
assert.equal(f.trip.returnRedispatch.exceptionNo, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) {
|
||||
test('two-box return never borrows an inventory source from another master or a different source type', async () => {
|
||||
const f = fixture(); mutate(f.relations[0].returnSources[0])
|
||||
await assert.rejects(run(f), /Selected master must contain/)
|
||||
assert.ok(f.requests.every(row => row.method === 'GET'))
|
||||
})
|
||||
}
|
||||
|
||||
test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => {
|
||||
const f = fixture(); f.input.sourceSelectionRecovery = true
|
||||
assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
|
||||
assert.ok(!f.requests.some(row => row.path?.endsWith('/status?customerId=C')))
|
||||
})
|
||||
|
||||
test('reviewed source recovery refuses WAIT_DELIVERY before any delivery-start or return command', async () => {
|
||||
const f = fixture(); f.input.sourceSelectionRecovery = true
|
||||
const request = f.context.request
|
||||
f.context.request = async (...args) => {
|
||||
const r = await request(...args)
|
||||
if (r.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') r.body.data.order.status = 'WAIT_DELIVERY'
|
||||
return r
|
||||
}
|
||||
await assert.rejects(run(f), /never start it again/)
|
||||
assert.ok(f.requests.every(row => row.method === 'GET'))
|
||||
})
|
||||
|
||||
test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => {
|
||||
test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => {
|
||||
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.shippedPackages, 2)
|
||||
assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token'))
|
||||
assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256))
|
||||
const uploads = f.trip.returnRedispatch.requests.filter(row => row.file)
|
||||
assert.ok(uploads.length > 0)
|
||||
assert.ok(uploads.every(row => Number.isSafeInteger(row.file.sizeBytes) && row.file.sizeBytes > 0 && !Object.hasOwn(row.file, 'bytes')))
|
||||
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.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.equal(f.requests.some(r => r.path.includes('/pre-arrange')), false)
|
||||
})
|
||||
|
||||
for (const missing of [false, true]) test(`unapproved or missing pre-signature gate cannot sign or close new trip; missing=${missing}`, async () => {
|
||||
const f = fixture('audit-gate')
|
||||
if (missing) delete f.dependencies.onBeforeRedispatchSignature
|
||||
const original = f.dependencies.saveReceiptAndComplete
|
||||
f.dependencies.saveReceiptAndComplete = async (...args) => { assert.notEqual(args[1].deliveryNo, 'DL-NEW'); return original(...args) }
|
||||
f.dependencies.finishFleetTrip = () => assert.fail('new trip must not close before audited signature')
|
||||
await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/)
|
||||
assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING')
|
||||
assert.equal(f.trip.returnRedispatch.afterRedispatch, undefined)
|
||||
for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`invalid original signature quantity ${signedPackages} stops before new Create`, async () => {
|
||||
const f = fixture(); f.controls.originalQuantity = { override: true, value: signedPackages }; await assert.rejects(run(f), /stored quantity/)
|
||||
assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0); assert.equal(f.requests.some(r => r.path === DISPATCH && r.method === 'POST'), false)
|
||||
})
|
||||
for (const signedPackages of [undefined, null, '2', 0, 3]) test(`new signature must contain numeric exactly two boxes: ${signedPackages}`, async () => {
|
||||
const f = fixture(); f.controls.newQuantity = { override: true, value: signedPackages }; await assert.rejects(run(f), /two boxes actually signed/)
|
||||
assert.equal(f.requests.some(r => r.path.endsWith('/TR-NEW/complete')), false)
|
||||
})
|
||||
|
||||
test('actual selected master works without ordinary inbound relationship and uses immutable return receipt selector', async () => {
|
||||
const f = fixture('no-inbound'); assert.equal((await run(f)).returnReceiptId, '300'); assert.ok(f.relations.every(r => r.inboundOrderId == null))
|
||||
assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 2)
|
||||
const create = f.requests.find(r => r.path === DISPATCH && r.body.cargo.length); assert.equal(create.body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(create.body.cargo[0].planPalletId, null)
|
||||
})
|
||||
for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) test(`reject return provenance ${fault} before all writes`, async () => {
|
||||
const f = fixture(), request = f.context.request
|
||||
if (fault === 'missing-master') delete f.relations[0].lastMileOrderId
|
||||
if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER'
|
||||
if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER'
|
||||
if (fault === 'foreign-customer') f.relations[0].customerId = '999'
|
||||
if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = '999'
|
||||
if (fault === 'borrowed-master') f.context.request = async (...args) => { const response = await request(...args); if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B; return response }
|
||||
await assert.rejects(run(f), /real master ID|match this candidate/); assert.ok(f.requests.every(r => r.method === 'GET'))
|
||||
})
|
||||
for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => {
|
||||
const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET'))
|
||||
})
|
||||
for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'missing-measurements', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => {
|
||||
const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED')
|
||||
if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET'))
|
||||
if (fault === 'missing-measurements') assert.equal(f.requests.some(r => r.path.endsWith('/loading-complete') || r.path.endsWith('/transfer-delivery')), false)
|
||||
})
|
||||
test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => {
|
||||
const f = fixture('shared-root'), proof = await run(f)
|
||||
assert.equal(proof.rootsBeforeRedispatch[0].root.order.status, 'PROCESSING')
|
||||
assert.equal(proof.rootsBeforeRedispatch[0].children.find(c => c.id === '701').status, 'PROCESSING')
|
||||
assert.equal(proof.rootsBeforeRedispatch[0].children.find(c => c.id === '702').status, 'COMPLETED')
|
||||
assert.ok(proof.rootsAfterRedispatch.every(s => s.root.order.status === 'COMPLETED' && s.children.every(c => c.status === 'COMPLETED')))
|
||||
})
|
||||
for (const missing of [false, true]) test(`unapproved or missing readonly audit prevents new signature and closure; missing=${missing}`, async () => {
|
||||
const f = fixture('audit-gate'); if (missing) delete f.dependencies.onBeforeRedispatchSignature
|
||||
await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/); assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING')
|
||||
assert.equal(f.requests.some(r => r.method === 'PUT' && r.path.includes('/DL-NEW/documents')), false); assert.equal(f.requests.some(r => r.path.endsWith('/TR-NEW/complete')), false)
|
||||
})
|
||||
test('core producer, pick and successful signature cannot be externally substituted', async () => {
|
||||
for (const name of ['prepareFleetTrip', 'confirmFleetLoading', 'pickAll', 'saveReceiptAndComplete', 'finishFleetTrip']) { const f = fixture(); f.dependencies[name] = async () => ({ success: true }); await assert.rejects(run(f), /cannot be replaced/); assert.equal(f.requests.length, 0) }
|
||||
})
|
||||
test('native owned departure guard rejects old status and wrong original whole before HTTP', async () => {
|
||||
const f = fixture(); f.context.createdResources.fleetAcceptance.departure.status = 'IN_TRANSIT'; await assert.rejects(run(f), /owned departure/); assert.equal(f.requests.length, 0)
|
||||
const other = fixture(); other.input.dispatchNos = ['BORROWED']; await assert.rejects(run(other), /owned departure/); assert.equal(other.requests.length, 0)
|
||||
})
|
||||
test('reviewed source selection retains started deliveries and refuses to repeat WAIT_DELIVERY start', async () => {
|
||||
const f = fixture(); f.input.sourceSelectionRecovery = true; await run(f); assert.equal(f.requests.some(r => r.path.includes('/status')), false)
|
||||
const waiting = fixture(); waiting.input.sourceSelectionRecovery = true; waiting.controls.waitDelivery = true; await assert.rejects(run(waiting), /never start it again/); assert.ok(waiting.requests.every(r => r.method === 'GET'))
|
||||
})
|
||||
test('fresh WAIT_DELIVERY start preserves exact server Long version including zero', async () => {
|
||||
for (const version of [0, '9223372036854775807']) { const f = fixture(); f.controls.waitDelivery = true; f.controls.deliveryVersion = version; await run(f); const started = f.requests.find(r => r.path.includes('/status')); assert.equal(started.body.expectedVersion, version) }
|
||||
})
|
||||
test('fixture source A/B selection refuses borrowed or partial shipments', () => {
|
||||
const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } })
|
||||
assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/)
|
||||
})
|
||||
test('write-ahead failure sends no HTTP and unknown return preserves original key without restart', async () => {
|
||||
const disk = fixture(); disk.context.checkpoint = async () => { throw Error('disk full') }; await assert.rejects(run(disk), /disk full/); assert.equal(disk.requests.length, 0)
|
||||
const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/); const last = f.checkpoints.at(-1).requests.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey)
|
||||
const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count)
|
||||
})
|
||||
for (const [name, response] of Object.entries({ http0: { http: 0, body: null }, server500: { http: 500, body: { code: 'B0001' }, requestId: 'r', traceId: 't' }, proxy502: { http: 502, body: null }, malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, htmlFailure: { http: 403, body: '<html>blocked</html>', requestId: 'r', traceId: 't' }, uncorrelatedSuccess: { http: 200, body: { code: '200', data: {} } } })) test(`ambiguous ${name} stops without automatic replay`, async () => {
|
||||
const f = fixture(), request = f.context.request; f.context.request = async (...args) => { if (args[1].includes('/mark-return')) { f.requests.push({ method: args[0], path: args[1], body: copy(args[2]) }); return response }; return request(...args) }
|
||||
await assert.rejects(run(f), /outcome unknown/); const last = f.checkpoints.at(-1).requests.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey)
|
||||
assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 1); const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count)
|
||||
})
|
||||
test('unknown multipart writes retain file hash and stop before return stock receipt', async () => {
|
||||
const f = fixture(); f.context.requestMultipart = async (method, path, file) => { f.requests.push({ method, path, file: file.name }); return { http: 0, body: null } }
|
||||
await assert.rejects(run(f), /outcome unknown/); const entry = f.checkpoints.at(-1).requests.at(-1); assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.match(entry.file.sha256, /^[a-f0-9]{64}$/)
|
||||
assert.equal(f.requests.some(r => r.path.includes('/exception-orders/') && r.path.includes('/complete')), false)
|
||||
})
|
||||
test('correlated business conflict is retained as a definite rejection without retry', async () => {
|
||||
const f = fixture(), request = f.context.request; f.context.request = async (...args) => { if (args[1].includes('/mark-return')) { f.requests.push({ method: args[0], path: args[1], body: copy(args[2]) }); return { http: 409, body: { code: 'A0409' }, requestId: 'r', traceId: 't' } }; return request(...args) }
|
||||
await assert.rejects(run(f), /HTTP failed/); assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED'); assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 1)
|
||||
})
|
||||
|
||||
for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test(`file gate requires exact checkpoint and hashed source audit: ${fault}`, async () => {
|
||||
@@ -206,97 +249,3 @@ for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) {
|
||||
test(`rejects ${fault} instead of declaring redispatch complete`, async () => {
|
||||
const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED')
|
||||
if (fault === 'missing-B') assert.ok(f.requests.every(row => row.method === 'GET'))
|
||||
})
|
||||
}
|
||||
test('unknown return outcome persists the exact original key and cannot automatically restart', async () => {
|
||||
const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/)
|
||||
const last = f.checkpoints.at(-1).requests.at(-1)
|
||||
assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey)
|
||||
const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count)
|
||||
})
|
||||
test('pre-send checkpoint failure cannot send even the first HTTP request', async () => {
|
||||
const f = fixture(); f.context.checkpoint = async () => { throw Error('disk full') }
|
||||
await assert.rejects(run(f), /disk full/); assert.equal(f.requests.length, 0)
|
||||
})
|
||||
test('fixture selection refuses partial coverage or borrowed source shipments', () => {
|
||||
const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 },
|
||||
relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } })
|
||||
assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/)
|
||||
})
|
||||
|
||||
test('WAIT_DELIVERY start submits exactly the version read from the current delivery', async () => {
|
||||
const f = fixture(), original = f.context.request.bind(f.context)
|
||||
let started = false
|
||||
f.context.request = async (method, path, body, ...args) => {
|
||||
if (method === 'PUT' && path.includes('/delivery-orders/DL-ORIGINAL/status')) {
|
||||
assert.equal(body.expectedVersion, 17); assert.equal(body.status, 'DELIVERING'); started = true
|
||||
f.requests.push({ method, path, body })
|
||||
return { http: 200, body: { code: '200', data: { deliveryStatus: 'DELIVERING' } }, requestId: 'r', traceId: 't' }
|
||||
}
|
||||
const response = await original(method, path, body, ...args)
|
||||
if (!started && response.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') Object.assign(response.body.data.order, { status: 'WAIT_DELIVERY', version: '17' })
|
||||
if (path.includes('/return-inbound-options')) assert.equal(started, true)
|
||||
return response
|
||||
}
|
||||
await run(f)
|
||||
assert.equal(f.requests.filter(row => row.method === 'PUT' && row.path.includes('/status')).length, 1)
|
||||
})
|
||||
|
||||
for (const multipart of [false, true]) test(`http:0 records UNKNOWN_RESULT and stops without retry; multipart=${multipart}`, async () => {
|
||||
const f = fixture(), original = f.context.request.bind(f.context)
|
||||
if (multipart) f.context.requestMultipart = async (method, path, file) => {
|
||||
f.requests.push({ method, path, file: file.name }); return { http: 0, body: null, requestId: null, traceId: null }
|
||||
}
|
||||
else f.context.request = async (method, path, body, ...args) => {
|
||||
if (path.includes('/exception-orders/') && path.includes('/complete')) {
|
||||
f.requests.push({ method, path, body: structuredClone(body) }); return { http: 0, body: null, requestId: null, traceId: null }
|
||||
}
|
||||
return original(method, path, body, ...args)
|
||||
}
|
||||
await assert.rejects(run(f), /transport outcome unknown/)
|
||||
const last = f.checkpoints.at(-1).requests.at(-1)
|
||||
assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, 0)
|
||||
if (multipart) assert.match(last.file.sha256, /^[a-f0-9]{64}$/)
|
||||
else assert.ok(last.body.idempotencyKey)
|
||||
assert.equal(f.requests.filter(row => row.path === last.path && row.method === last.method).length, 1)
|
||||
const count = f.requests.length
|
||||
await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count)
|
||||
})
|
||||
|
||||
for (const [name, response] of Object.entries({
|
||||
server500: { http: 500, body: { code: 'B0001', message: 'Internal error' }, requestId: 'r', traceId: 't' },
|
||||
proxy502: { http: 502, body: null, requestId: null, traceId: null },
|
||||
malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' },
|
||||
htmlFailure: { http: 403, body: '<html>proxy blocked</html>', requestId: 'r', traceId: 't' },
|
||||
uncorrelatedSuccess: { http: 200, body: { code: '200', data: { status: 'COMPLETED' } }, requestId: null, traceId: null },
|
||||
})) test(`ambiguous ${name} persists UNKNOWN_RESULT without replay or further requests`, async () => {
|
||||
const f = fixture(), original = f.context.request.bind(f.context)
|
||||
f.context.request = async (method, path, body, ...args) => {
|
||||
if (path.includes('/mark-return')) { f.requests.push({ method, path, body: structuredClone(body) }); return response }
|
||||
return original(method, path, body, ...args)
|
||||
}
|
||||
await assert.rejects(run(f), /outcome unknown/)
|
||||
const last = f.checkpoints.at(-1).requests.at(-1)
|
||||
assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, response.http)
|
||||
assert.ok(last.body.idempotencyKey)
|
||||
assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1)
|
||||
assert.equal(f.requests.at(-1).path, last.path)
|
||||
const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count)
|
||||
})
|
||||
|
||||
test('correlated explicit business rejection remains RESPONDED and also stops without replay', async () => {
|
||||
const f = fixture(), original = f.context.request.bind(f.context)
|
||||
f.context.request = async (method, path, body, ...args) => {
|
||||
if (path.includes('/mark-return')) {
|
||||
f.requests.push({ method, path, body }); return { http: 409, body: { code: 'A0409', message: 'version conflict' }, requestId: 'r', traceId: 't' }
|
||||
}
|
||||
return original(method, path, body, ...args)
|
||||
}
|
||||
await assert.rejects(run(f), /HTTP failed/)
|
||||
assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED')
|
||||
assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1)
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@ 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 } from './phase5-overseas-fulfillment.mjs'
|
||||
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } 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'
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Unsafe server ID; retain Long string'); return value == null ? '' : String(value) }
|
||||
const key = () => `redispatch-${randomUUID()}`
|
||||
const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}`
|
||||
const ok = response => response?.http === 200 && String(response?.body?.code) === '200'
|
||||
@@ -36,10 +37,14 @@ export function selectReturnSources(rows) {
|
||||
/** 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 || run.departure?.status !== 'IN_TRANSIT' || !input?.pickerToken
|
||||
|| run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART'
|
||||
|| !departure.result?.departedAt || !input?.pickerToken
|
||||
|| id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId)
|
||||
|| JSON.stringify((run.departure.dispatches || []).map(row => row.dispatchNo).sort()) !== JSON.stringify([...(input.dispatchNos || [])].sort())
|
||||
|| 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())
|
||||
|| !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,
|
||||
@@ -47,15 +52,18 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
'After redispatch signature and exact replay: A and B each have exactly one OVERSEAS_DELIVERY_COMPLETED outbox event by customer+sourceInboundId; inspect delivery ACK and domestic inbound completion separately',
|
||||
'Join return receipt ledger_id and source_outbound_no to immutable RETURN_STOCK_IN; join new picks by return_receipt_id; original shipment and signed facts remain unchanged'] }
|
||||
const check = (label, condition) => { if (!condition) throw Error(label) }
|
||||
let stopped = false
|
||||
const io = Object.create(context)
|
||||
io.assert = (...args) => { context.assert(...args); if (!args[3]) throw Error(args[1]) }
|
||||
async function invoke(method, path, body, token, file, options) {
|
||||
if (stopped) throw Error('Unknown prior outcome; preserve original command and stop')
|
||||
const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' }
|
||||
if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') }
|
||||
proof.requests.push(entry); await context.checkpoint()
|
||||
try {
|
||||
const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options)
|
||||
if (!definiteResponse(response)) {
|
||||
stopped = true; proof.outcomeUnknown = true
|
||||
Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
await context.checkpoint()
|
||||
@@ -66,16 +74,17 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
await context.checkpoint()
|
||||
check(`Missing HTTP correlation: ${method} ${path}`, response?.requestId && response?.traceId)
|
||||
return response
|
||||
} catch (error) { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } throw error }
|
||||
} catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error }
|
||||
}
|
||||
io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options)
|
||||
io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file)
|
||||
async function request(method, path, body, token) {
|
||||
const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data
|
||||
}
|
||||
const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete,
|
||||
onBeforeRedispatchSignature: context.context?.onBeforeRedispatchSignature,
|
||||
wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies }
|
||||
const deps = { onBeforeRedispatchSignature: dependencies.onBeforeRedispatchSignature || context.context?.onBeforeRedispatchSignature,
|
||||
wait: dependencies.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))) }
|
||||
const native = { command: (method, path, body, label) => request(method, path, body), check: (label, response, passed) => check(label, passed) }
|
||||
const replay = async (path, body, first, label) => { const again = await replayCommand(native, path, body, first, label); check(label, again.replayed === true); return again }
|
||||
const customerId = id(input.customerId), warehouseId = id(input.warehouseId)
|
||||
const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId))
|
||||
const delivery = number => request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}`, customerId))
|
||||
@@ -95,19 +104,52 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
}
|
||||
throw Error(allSigned ? 'Domestic source completion has not converged after redispatch signature' : 'Independent source B domestic completion did not converge')
|
||||
}
|
||||
async function roots(a, b, beforeSignature) {
|
||||
const snapshots = []
|
||||
const known = value => ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'].includes(value)
|
||||
for (const [name, selected] of [['A', a], ['B', b]]) {
|
||||
const sourceId = id(selected.tail.sourceOrderId)
|
||||
check('Original source order provenance is required for root and child guards', /^[1-9]\d*$/.test(sourceId))
|
||||
const rawPath = order => `/api/platform/v1/orders/first-leg/${encodeURIComponent(order)}?${new URLSearchParams({ tenantId: customerId })}`
|
||||
const source = await request('GET', rawPath(sourceId)), order = source?.order
|
||||
const rootId = order?.orderRole === 'MASTER' ? id(order.id) : id(order?.rootOrderId)
|
||||
check('Original source and fulfillment root belong to current customer', id(order?.id) === sourceId && id(order.customerId) === customerId && id(order.tenantId) === customerId
|
||||
&& known(order.status) && /^[1-9]\d*$/.test(rootId) && (order.orderRole === 'MASTER' || order.orderRole === 'CHILD' && id(order.parentOrderId) === rootId))
|
||||
const root = rootId === sourceId ? source : await request('GET', rawPath(rootId))
|
||||
const tree = await request('GET', `/api/platform/v1/orders/${rootId}/fulfillment-tree?${new URLSearchParams({ tenantId: customerId })}`)
|
||||
check('Formal root and explicit child list remain customer-owned', id(root.order?.id) === rootId && root.order.orderRole === 'MASTER' && id(root.order.customerId) === customerId && id(root.order.tenantId) === customerId && known(root.order.status)
|
||||
&& id(tree.parentOrderId) === rootId && tree.parentOrderNo === root.order.orderNo && Array.isArray(tree.children) && Array.isArray(root.statusHistory))
|
||||
const children = []
|
||||
for (const row of tree.children.filter(row => id(row.orderId) !== rootId)) {
|
||||
const child = await request('GET', rawPath(id(row.orderId)))
|
||||
check('Every formal child stays linked to the actual root', id(child.order?.id) === id(row.orderId) && child.order.orderRole === 'CHILD'
|
||||
&& id(child.order.rootOrderId) === rootId && id(child.order.parentOrderId) === rootId && id(child.order.customerId) === customerId && id(child.order.tenantId) === customerId && known(child.order.status) && child.order.orderNo === row.orderNo)
|
||||
children.push(child.order)
|
||||
}
|
||||
check('Original source is present in its real formal root tree', sourceId === rootId || children.some(child => id(child.id) === sourceId))
|
||||
const pending = children.some(child => !['COMPLETED', 'CANCELLED'].includes(child.status))
|
||||
const completed = root.statusHistory.some(event => event.action === 'OVERSEAS_DELIVERED') && !pending
|
||||
check('Root cannot complete ahead of valid children or overseas delivery fact', completed
|
||||
? root.order.status === 'COMPLETED' && root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED')
|
||||
: root.order.status !== 'COMPLETED' && !root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED'))
|
||||
if (beforeSignature && name === 'A') check('Incomplete A child cannot be completed before redispatch signature', order.status !== 'COMPLETED')
|
||||
snapshots.push({ source: name, sourceOrder: order, root, tree, children })
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
try {
|
||||
const rows = []
|
||||
proof.originalDeliveries = []
|
||||
for (const number of [...new Set(input.deliveryNos)]) {
|
||||
const current = await delivery(number)
|
||||
check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo))
|
||||
check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo)
|
||||
&& id(current.order.customerId) === customerId && id(current.order.overseasWarehouseId) === warehouseId)
|
||||
proof.originalDeliveries.push(copy(current.order))
|
||||
if (input.sourceSelectionRecovery) check('Reviewed source recovery must retain the already started delivery; never start it again', current.order.status === 'DELIVERING')
|
||||
if (current.order.status === 'WAIT_DELIVERY') {
|
||||
check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version))
|
||||
&& current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0)
|
||||
version(current.order.version)
|
||||
const started = await request('PUT', scoped(`${BASE}/delivery-orders/${number}/status`, customerId), {
|
||||
status: 'DELIVERING', expectedVersion: Number(current.order.version), remark: `${context.marker} current-run delivery underway`,
|
||||
status: 'DELIVERING', expectedVersion: version(current.order.version), remark: `${context.marker} current-run delivery underway`,
|
||||
})
|
||||
check('Return candidates are opened only after formal delivery start', started.deliveryStatus === 'DELIVERING')
|
||||
}
|
||||
@@ -132,10 +174,13 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
&& /^[1-9][0-9]*$/.test(id(row.inventoryPositionId)) && row.returnCartonId == null
|
||||
&& Number.isSafeInteger(Number(row.maxReturnPackages)) && Number(row.maxReturnPackages) >= 2)
|
||||
check('Selected master must contain the actual ordinary stock source for the two returned boxes', Boolean(source))
|
||||
const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), {
|
||||
const markPath = scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId)
|
||||
proof.markReturnRequest = {
|
||||
lastMileOrderId: id(a.relation.lastMileOrderId), reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE',
|
||||
returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(),
|
||||
})
|
||||
}
|
||||
const marked = await request('POST', markPath, proof.markReturnRequest)
|
||||
await replay(markPath, proof.markReturnRequest, marked, 'Mark-return original command replay')
|
||||
proof.exceptionNo = marked.exceptionNo
|
||||
const exceptionPath = `${BASE}/exception-orders/${encodeURIComponent(marked.exceptionNo)}`
|
||||
const resource = `/api/platform/v1/overseas-warehouse-resources/${warehouseId}`
|
||||
@@ -145,6 +190,34 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
const locationPage = await request('GET', `${resource}/locations?page=1&size=200`)
|
||||
const location = (Array.isArray(locationPage) ? locationPage : locationPage.records || []).find(row => id(row.id) === id(pallet.locationId))
|
||||
check('New return pallet has a real same-warehouse location', id(pallet.warehouseId) === warehouseId && location?.locationCode && pallet.palletCode === palletCode)
|
||||
const paged = async path => {
|
||||
const rows = []; let total
|
||||
for (let page = 1; ; page++) {
|
||||
const current = await request('GET', `${path}&page=${page}&size=200`)
|
||||
check('Inventory pagination must remain complete and stable', Array.isArray(current?.records) && Number.isSafeInteger(Number(current.total)) && Number(current.total) >= 0
|
||||
&& (total === undefined || total === Number(current.total)) && (current.records.length > 0 || rows.length === Number(current.total)))
|
||||
total ??= Number(current.total); rows.push(...current.records); if (rows.length >= total) { check('Inventory pagination cannot omit or duplicate rows', rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length); return rows }
|
||||
}
|
||||
}
|
||||
const inventory = async label => {
|
||||
const filters = new URLSearchParams({ customerId, overseasWarehouseId: warehouseId, inboundCode: a.relation.inboundCode })
|
||||
const balances = await paged(`${BASE}/inventory/balances?${filters}`), positions = []
|
||||
for (const balance of balances) {
|
||||
check('Returned balance belongs to original customer and warehouse', id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId)
|
||||
const rows = await request('GET', `${BASE}/inventory/balances/${id(balance.id)}/positions?${new URLSearchParams({ customerId, palletNo: palletCode })}`)
|
||||
check('Actual return positions are explicit rows', Array.isArray(rows))
|
||||
for (const row of rows.filter(row => id(row.palletId) === palletId)) {
|
||||
check('Returned stock identity stays within original customer warehouse and inbound', id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode)
|
||||
positions.push(row)
|
||||
}
|
||||
}
|
||||
const ledger = await paged(`${BASE}/inventory/ledger?${filters}&${new URLSearchParams({ businessType: 'RETURN_STOCK_IN', businessOrderNo: marked.exceptionNo, outboundOrderNo: a.delivery.outboundNo, palletNo: palletCode })}`)
|
||||
check('Return stock-in ledger is immutable original-shipment evidence', ledger.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId
|
||||
&& row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode && row.businessType === 'RETURN_STOCK_IN' && row.businessOrderNo === marked.exceptionNo && row.outboundOrderNo === a.delivery.outboundNo))
|
||||
const result = { label, positions, ledger, available: positions.reduce((n, row) => n + Number(row.availablePackages), 0), picked: positions.reduce((n, row) => n + Number(row.pickedPackages), 0) }
|
||||
proof.inventory ||= []; proof.inventory.push(copy(result)); return result
|
||||
}
|
||||
const empty = await inventory('BEFORE_RETURN'); check('Fresh target pallet contains no previous return stock', empty.available === 0 && empty.picked === 0 && empty.ledger.length === 0)
|
||||
const started = await request('POST', scoped(`${exceptionPath}/start-processing`, customerId), { expectedVersion: marked.version, idempotencyKey: key(), handlingAction: '实际退回2箱' })
|
||||
const uploaded = await io.requestMultipart('POST', scoped(`${exceptionPath}/evidence`, customerId), {
|
||||
name: `${context.marker}-return.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} synthetic evidence of 2 returned boxes`),
|
||||
@@ -155,13 +228,20 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
targetPalletId: palletId, targetPalletNo: palletCode, targetLocationId: location.id, targetLocationCode: location.locationCode }] }
|
||||
const restored = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt)
|
||||
check('Exactly two boxes restored', restored.status === 'COMPLETED' && Number(restored.restoredPackages) === 2)
|
||||
const replay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt)
|
||||
check('Same return key replays the same adjustment', replay.replayed === true && id(replay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId))
|
||||
const stockIn = await inventory('RETURN_STOCK_IN')
|
||||
check('Actual two returned boxes produce exactly one stock-in ledger', stockIn.available === 2 && stockIn.picked === 0 && stockIn.ledger.length === 1 && Number(stockIn.ledger[0].deltaPackages) === 2)
|
||||
const restoredReplay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt)
|
||||
check('Same return key replays the same adjustment', restoredReplay.replayed === true && id(restoredReplay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId))
|
||||
const stockReplay = await inventory('RETURN_REPLAY')
|
||||
check('Return replay cannot duplicate stock or ledger', stockReplay.available === stockIn.available && stockReplay.picked === stockIn.picked && JSON.stringify(stockReplay.ledger) === JSON.stringify(stockIn.ledger) && JSON.stringify(stockReplay.positions) === JSON.stringify(stockIn.positions))
|
||||
const history = await request('GET', scoped(exceptionPath, customerId))
|
||||
check('Owned exception exposes the actual unique returned receipt business number', history?.order?.exceptionNo === marked.exceptionNo && id(history.order.customerId) === customerId
|
||||
&& history.order.outboundNo === a.delivery.outboundNo && history.returnReceipts?.length === 1 && history.returnReceipts[0].receiptNo && Number(history.returnReceipts[0].receivedPackages) === 2)
|
||||
proof.returned = { palletId, palletCode, adjustmentId: restored.inventoryAdjustmentId }
|
||||
proof.originalSignatures = []
|
||||
for (const [index, number] of input.deliveryNos.entries()) {
|
||||
const current = await delivery(number)
|
||||
const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index)
|
||||
const signed = await saveReceiptAndComplete(io, current.order, customerId, 171 + index)
|
||||
check('Original remaining cargo actually signed with a stored quantity', signed?.order?.signedAt
|
||||
&& Number.isSafeInteger(signed.order.signedPackages) && signed.order.signedPackages >= 0)
|
||||
proof.originalSignatures.push(copy(signed.order))
|
||||
@@ -170,35 +250,42 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
check('A remains incomplete while independently complete B is delivered', !beforeA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && beforeB.order.subOrderStatus?.includes('DELIVERY:DELIVERED'))
|
||||
proof.beforeRedispatch = { A: beforeA, B: beforeB }
|
||||
proof.domesticBeforeRedispatch = await domesticBoundary(a, b, false)
|
||||
const candidates = await request('GET', `${BASE}/dispatches/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: a.relation.inboundCode })}`)
|
||||
const matches = candidates.filter(row => row.sourceType === 'RETURN_RECEIPT' && id(row.warehousePalletId) === palletId && row.returnReceiptNo && id(row.orderId) === id(a.relation.lastMileOrderId))
|
||||
check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].packageCount) === 2 && !matches[0].planPalletId)
|
||||
const selected = matches[0], draftToken = key()
|
||||
const container = await request('POST', `${BASE}/dispatches/drafts/containers`, { draftToken, capacity: 30 })
|
||||
await request('POST', `${BASE}/dispatches/draft-locks/lock`, { sourceType: 'RETURN_RECEIPT', returnReceiptId: selected.returnReceiptId, draftToken, draftContainerId: container.id })
|
||||
const arrange = { planPalletIds: [], returnReceiptIds: [selected.returnReceiptId], draftToken }
|
||||
const arranged = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange)
|
||||
check('Redispatch is a new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) && Number(arranged.packageCount) === 2)
|
||||
const arrangedReplay = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange)
|
||||
check('Draft replay keeps the same redispatch', arrangedReplay.dispatchNo === arranged.dispatchNo)
|
||||
const newTrip = await deps.prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId)
|
||||
proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = selected.returnReceiptNo; proof.redispatchNo = arranged.dispatchNo
|
||||
const tripDetail = await request('GET', `${BASE}/fleet/trips/${newTrip.tripNo}`)
|
||||
const transfer = await request('POST', `${BASE}/dispatches/${arranged.dispatchNo}/transfer-delivery`, {
|
||||
idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), deliveryMethod: 'TRUCK_PALLET',
|
||||
pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托',
|
||||
vehiclePlate: tripDetail.trip.vehiclePlate, driverName: tripDetail.trip.driverName, driverPhone: tripDetail.trip.driverPhone, appointmentAccount: 'SELF',
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '退回重派验收费用', direction: 'RECEIVABLE', currency: 'CNY', amount: 1 }], remark: context.marker,
|
||||
})
|
||||
check('Redispatch produces a new single destination delivery', transfer.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo))
|
||||
check('Fresh return stock can be picked via PDA', await deps.pickAll(io, input.pickerToken, arranged.dispatchNo))
|
||||
const recordVersion = await deps.confirmFleetLoading(io, module, newTrip)
|
||||
const departBody = { recordVersion, idempotencyKey: key(), remark: '退回2箱重新实际发车' }
|
||||
proof.rootsBeforeRedispatch = await roots(a, b, true)
|
||||
const candidates = await candidatePage(native, warehouseId, { orderNo: a.relation.inboundCode })
|
||||
const matches = candidates.filter(row => row.selection?.sourceType === 'RETURN_RECEIPT' && id(row.selection.warehousePalletId) === palletId
|
||||
&& id(row.customerId) === customerId && id(row.warehouseId) === warehouseId && row.orderNo === a.tail.orderNo && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode)
|
||||
check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].availablePackages) === 2 && matches[0].selection.planPalletId == null)
|
||||
const selected = matches[0]
|
||||
proof.createRequest = createBody(context.marker, 'RETURN-REDISPATCH', [selected])
|
||||
const arranged = await request('POST', `${BASE}/dispatches`, proof.createRequest)
|
||||
check('Redispatch is a native new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo)
|
||||
&& arranged.action === 'CREATE' && arranged.status === 'ACTIVE' && arranged.stage === 'BEFORE_LOADING' && Number(arranged.counts?.packageCount) === 2)
|
||||
await replay(`${BASE}/dispatches`, proof.createRequest, arranged, 'Native Create original-key replay keeps the same redispatch')
|
||||
const actual = await readDispatch(native, arranged.dispatchNo)
|
||||
check('Created redispatch readback preserves exact returned receipt customer and stock identity', actual.warehouseId === warehouseId && actual.items.items.length === 1
|
||||
&& actual.items.items[0].sourceKey === selected.sourceKey && id(actual.items.items[0].returnReceiptId) === id(selected.selection.returnReceiptId)
|
||||
&& id(actual.items.items[0].warehousePalletId) === palletId && id(actual.items.items[0].customerId) === customerId && Number(actual.items.items[0].scheduledPackages) === 2)
|
||||
const newTrip = await prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId)
|
||||
proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = history.returnReceipts[0].receiptNo; proof.returnReceiptId = selected.selection.returnReceiptId; proof.redispatchNo = arranged.dispatchNo
|
||||
check('Fresh return stock can be picked via PDA', await pickAll(io, input.pickerToken, arranged.dispatchNo))
|
||||
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.tripNo === newTrip.tripNo && Number(departed.shippedPackages) === 2)
|
||||
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)
|
||||
check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2)
|
||||
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 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,
|
||||
departed, originalSignatureCount: proof.originalSignatures.length }
|
||||
@@ -209,7 +296,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
&& audit.sourceBEventCount === 1 && /^[a-f0-9]{64}$/.test(audit.auditEvidenceSha256 || ''))
|
||||
Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit })
|
||||
await context.checkpoint()
|
||||
const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271)
|
||||
const newSignature = await saveReceiptAndComplete(io, newDelivery.order, customerId, 271)
|
||||
check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && newSignature.order.signedPackages === 2)
|
||||
const afterA = await tail(a.relation), afterB = await tail(b.relation)
|
||||
check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus)
|
||||
@@ -219,7 +306,8 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
|
||||
}
|
||||
proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed }
|
||||
proof.domesticAfterRedispatch = await domesticBoundary(a, b, true)
|
||||
proof.closure = await deps.finishFleetTrip(io, module, newTrip)
|
||||
proof.rootsAfterRedispatch = await roots(a, b, false)
|
||||
proof.closure = await finishFleetTrip(io, module, newTrip)
|
||||
check('New trip cargo and resource lifecycle completes', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
proof.status = 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'; proof.sourceEventsVerified = false
|
||||
await context.checkpoint(); return proof
|
||||
|
||||
@@ -5,10 +5,10 @@ import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } fro
|
||||
const copy = value => value === undefined ? undefined : structuredClone(value)
|
||||
const prefix = 'platform:fleet:trip:'
|
||||
function fixture(fault, multiDestination = false) {
|
||||
const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map()
|
||||
const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT', dispatchNo: 'DP-OWN' }, calls = [], rows = [], histories = new Map(), keys = new Map()
|
||||
const shares = [
|
||||
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] },
|
||||
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] },
|
||||
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] },
|
||||
]
|
||||
const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' }
|
||||
const depart = { recordVersion: 7, action: 'DEPART', snapshot: {
|
||||
@@ -17,8 +17,8 @@ function fixture(fault, multiDestination = false) {
|
||||
confirmedLoad: { manifestHash: 'original-manifest', pallets: [
|
||||
{ warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }],
|
||||
cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5, destinationKey: 'FBA:A' },
|
||||
{ warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] },
|
||||
externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND',
|
||||
{ warehousePalletId: 'P2', dispatchNo: 'DP-OWN', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] },
|
||||
externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', customerId: 'C2', status: 'BOUND',
|
||||
receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } }
|
||||
const load = depart.snapshot.confirmedLoad
|
||||
if (multiDestination) {
|
||||
@@ -57,7 +57,7 @@ function fixture(fault, multiDestination = false) {
|
||||
allocations: fault === 'invented allocation' ? [{ businessNo: 'DP-OWN', amount: amount / 100 }] : [] }
|
||||
return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [
|
||||
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 },
|
||||
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL',
|
||||
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL',
|
||||
palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] }
|
||||
}) }
|
||||
}
|
||||
@@ -71,7 +71,8 @@ function fixture(fault, multiDestination = false) {
|
||||
}
|
||||
const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip],
|
||||
fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo,
|
||||
departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } },
|
||||
departure: { dispatchNo: trip.dispatchNo, status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART',
|
||||
result: { departedAt: depart.snapshot.trip.departedAt } } } },
|
||||
assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {},
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: copy(body), actor: token })
|
||||
@@ -178,6 +179,23 @@ test('missing current-run membership or permission session fails before HTTP', a
|
||||
}
|
||||
})
|
||||
|
||||
test('cost execution requires the current single-dispatch native departure, without a legacy Fleet result alias', async () => {
|
||||
for (const mutate of [
|
||||
f => { f.context.createdResources.fleetAcceptance.departure = { tripNo: f.trip.tripNo, status: 'IN_TRANSIT', departedAt: '2026-10-03T01:00:00Z' } },
|
||||
f => { f.context.createdResources.fleetAcceptance.departure.dispatchNo = 'OTHER' },
|
||||
f => { f.context.createdResources.fleetAcceptance.departure.stage = 'AFTER_LOADING' },
|
||||
f => { f.context.createdResources.fleetAcceptance.departure.result.departedAt = null },
|
||||
f => { f.inputs.shares[1].dispatchNo = 'SECOND-DISPATCH' },
|
||||
]) {
|
||||
const f = fixture(); mutate(f)
|
||||
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs))
|
||||
assert.equal(f.calls.length, 0)
|
||||
}
|
||||
const f = fixture()
|
||||
f.context.createdResources.fleetAcceptance.departure.result.departedAt = '2026-10-03T01:00:00'
|
||||
assert.equal((await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)).status, 'VERIFIED')
|
||||
})
|
||||
|
||||
test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => {
|
||||
const f = fixture(), original = f.context.request
|
||||
f.context.request = async (...args) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ const cents = value => {
|
||||
if (!match) throw new Error('成本金额不是非负两位小数')
|
||||
return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
|
||||
}
|
||||
const departureMillis = value => typeof value === 'string' && value
|
||||
? Date.parse(/(?:Z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`) : NaN
|
||||
export const TRIP_COST_EXPECTATIONS = Object.freeze({ initialCny: '100.01', finalCny: '100.03', usd: '13.07', rows: 2,
|
||||
history: { CNY: ['CREATE', 'UPDATE'], USD: ['CREATE'] }, allocationMethod: 'SAME_ROUTE_PALLET_SPACES' })
|
||||
|
||||
@@ -38,11 +40,13 @@ export function expectedAllocations(amount, shares) {
|
||||
export async function verifyFleetTripCosts(context, module, tripProof, inputs = {}) {
|
||||
const run = context.createdResources?.fleetAcceptance, shares = inputs.shares
|
||||
if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker
|
||||
|| run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || run.departure?.tripNo !== tripProof.tripNo
|
||||
|| run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt || tripProof.costAcceptance
|
||||
|| run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || !tripProof.dispatchNo
|
||||
|| run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.action !== 'DEPART'
|
||||
|| run.departure.status !== 'ACTIVE' || run.departure.stage !== 'DELIVERING'
|
||||
|| !Number.isFinite(departureMillis(run.departure.result?.departedAt)) || tripProof.costAcceptance
|
||||
|| !inputs.listOnlyToken || !inputs.deniedToken || inputs.listOnlyToken === inputs.deniedToken
|
||||
|| !Array.isArray(shares) || !shares.length || new Set(shares.map(row => row.businessNo)).size !== shares.length
|
||||
|| shares.some(row => !row.businessNo || !row.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType)
|
||||
|| shares.some(row => !row.businessNo || row.dispatchNo !== tripProof.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType)
|
||||
|| !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1 || !Array.isArray(row.warehousePalletIds)
|
||||
|| !row.warehousePalletIds.length || row.warehousePalletIds.some(id => !id)))
|
||||
throw new Error('车次成本验收须提供本轮唯一真实DEPART车次、独立托位预期及两类权限会话;禁止借旧样本或重复开始')
|
||||
@@ -113,7 +117,8 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs =
|
||||
&& history.records.filter(row => row.action === 'DEPART').length === 1)
|
||||
departure = history.records.find(row => row.action === 'DEPART'); proof.departure = clone(departure)
|
||||
const snapshot = departure.snapshot, load = snapshot?.confirmedLoad
|
||||
check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo && snapshot.trip.departedAt === run.departure.departedAt
|
||||
check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo
|
||||
&& departureMillis(snapshot.trip.departedAt) === departureMillis(run.departure.result.departedAt)
|
||||
&& load?.manifestHash && load.pallets?.length > 0 && load.cargo?.length > 0)
|
||||
const ids = shares.flatMap(row => row.warehousePalletIds).map(String)
|
||||
check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length
|
||||
|
||||
@@ -9,7 +9,7 @@ test('default fulfillment runs finance verification using its own departed trip
|
||||
const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8')
|
||||
assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/)
|
||||
assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/)
|
||||
assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/)
|
||||
assert.match(source, /businesses: deliveries\.map\(row => \(\{ customerId: id\(row\.customerId\), deliveryNo: row\.deliveryNo \}\)\)/)
|
||||
})
|
||||
function fixture(status = 'AVAILABLE', received = null) {
|
||||
return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true,
|
||||
@@ -42,6 +42,19 @@ test('currencies are reconciled separately without floating point addition', ()
|
||||
value.finance.totals[0].receivableAmount = '100.23'
|
||||
assert.throws(() => validateTripFinance(value, scope), /原币/)
|
||||
})
|
||||
|
||||
test('one dispatch retains each delivery customer in whole-trip finance attribution', () => {
|
||||
const value = fixture()
|
||||
const second = { customerId: '9007199254740994', deliveryNo: 'DL-SECOND' }
|
||||
const mixed = { ...scope, businesses: [...scope.businesses, second] }
|
||||
value.finance.sources.push({ ...value.finance.sources[0], customerId: second.customerId,
|
||||
businessNo: second.deliveryNo, sourceNo: 'FS-SECOND', amount: '20.07' })
|
||||
value.finance.totals[0].receivableAmount = '120.10'
|
||||
assert.equal(validateTripFinance(value, mixed), true)
|
||||
assert.throws(() => validateTripFinance(value, scope), /其他客户或派送/)
|
||||
value.finance.sources[1].customerId = scope.businesses[0].customerId
|
||||
assert.throws(() => validateTripFinance(value, mixed), /其他客户或派送/)
|
||||
})
|
||||
test('pending allocation is explicitly unknown and never promoted to complete or zero', () => {
|
||||
const value = fixture('BILLED')
|
||||
value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null,
|
||||
|
||||
@@ -2,272 +2,382 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
|
||||
const stops = [{ name: 'Origin' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }]
|
||||
const cargo = (customerId = 'A') => ({ dispatchNo: `DP-${customerId}`, customerId, warehousePalletId: '9007199254740993', palletNo: 'PLT-1', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' })
|
||||
const D = '/api/platform/v1/last-leg/dispatches/DP-A'
|
||||
const P = '/api/pda/v1/last-leg/dispatches/DP-A'
|
||||
const F = '/api/platform/v1/last-leg/fleet/trips/TR-A'
|
||||
const stops = [{ name: '真实起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }]
|
||||
const VID = '9007199254740993', DRIVER = '9007199254740995', PALLET = '9007199254740997'
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } })
|
||||
const conflict = message => ({ http: 200, body: { code: 'A0409', message } })
|
||||
const evidence = (field, value) => ({ field, value, actual: true, sourceType: 'TEST_PRODUCER_MEASUREMENT', sourceBusinessNo: 'RECEIPT-A', observedAt: '2026-10-18T10:00:00Z' })
|
||||
const pallet = () => ({ warehousePalletId: PALLET, palletNo: 'PLT-A', scheduledPackages: 3, pickedPackages: 3,
|
||||
palletSpaces: null, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1, missingFields: ['palletSpaces'],
|
||||
evidence: [evidence('weightKg', '187.25'), evidence('volumeCbm', '1.375')] })
|
||||
const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo: 'DP-A', pallets: [
|
||||
{ warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] },
|
||||
] })
|
||||
|
||||
test('test loading measures physical pallet once across customers and rejects conflicting unloading points', () => {
|
||||
assert.deepEqual(controlledPalletMeasurements([cargo(), cargo('B')], stops), [
|
||||
{ warehousePalletId: '9007199254740993', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 },
|
||||
])
|
||||
assert.throws(() => controlledPalletMeasurements([{ ...cargo(), warehousePalletId: null }], stops), /缺少真实托盘/)
|
||||
assert.throws(() => controlledPalletMeasurements([cargo()], [{ name: 'Origin' }]), /不在车次路线/)
|
||||
assert.throws(() => controlledPalletMeasurements([cargo(), { ...cargo('B'), destinationKey: 'FBA:LAX9' }], [...stops, { destinationKey: 'FBA:LAX9' }]), /须先拆托/)
|
||||
})
|
||||
|
||||
test('fixture prepares actual qualification upload and review before assignment and copies authoritative destination keys', async () => {
|
||||
const calls = []
|
||||
let version = 0
|
||||
const context = {
|
||||
marker: 'TEST-FLEET', createdResources: {},
|
||||
apiSuccess(_module, _label, response) { return response.body.data },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body })
|
||||
let data
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE', 'VehicleCreate requires an explicit status and trip assignment requires an in-service vehicle')
|
||||
data = { id: 'vehicle-id' }
|
||||
}
|
||||
else if (path.endsWith('/drivers')) data = { id: 'driver-id', recordVersion: 0 }
|
||||
else if (path.includes('/dispatches/') && path.endsWith('/review')) data = { trip: { recordVersion: version++ }, dispatches: [{ dispatchNo: 'DP-A', review: { status: 'APPROVED' } }] }
|
||||
else if (path.endsWith('/review')) data = { reviewStatus: 'APPROVED' }
|
||||
else if (path.endsWith('/loading')) data = { cargo: [cargo()] }
|
||||
else { data = { trip: { tripNo: 'TR-FIXTURE', recordVersion: version++, stops: body.stops || stops } } }
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
},
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path, file })
|
||||
assert.equal(file.type, 'image/png'); assert.ok(file.content.byteLength > 0)
|
||||
return { http: 200, body: { code: '200', data: { id: 'driver-id', recordVersion: 1, licenseFileId: 'file-id' } } }
|
||||
},
|
||||
test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => {
|
||||
const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets)
|
||||
for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) {
|
||||
const actual = controlledPalletMeasurements([{ ...pallet(), ...counts }], stops, receipt().pallets)
|
||||
assert.deepEqual(actual, expected)
|
||||
assert.equal(actual[0].warehousePalletId, PALLET)
|
||||
}
|
||||
const proof = await prepareFleetTrip(context, 'test', 'DP-A', '92')
|
||||
assert.equal(proof.tripNo, 'TR-FIXTURE')
|
||||
assert.equal(proof.reassignment.status, 'PENDING')
|
||||
assert.ok(calls.findIndex(call => call.path.includes('/license?')) < calls.findIndex(call => call.path.endsWith('/review')))
|
||||
assert.ok(calls.findIndex(call => call.path.endsWith('/review')) < calls.findIndex(call => call.path.endsWith('/assignment')))
|
||||
assert.equal(calls.find(call => call.path.endsWith('/plan')).body.stops[1].destinationKey, 'FBA:ONT8')
|
||||
assert.equal(context.createdResources.fleetTrips[0].licenseFileId, 'file-id')
|
||||
assert.ok(calls.every(call => !call.path.endsWith('/depart')))
|
||||
for (const value of [null, undefined, '', '03', '3.0', '3e0', true, -1, '9007199254740993', 9007199254740992]) {
|
||||
assert.throws(() => controlledPalletMeasurements([{ ...pallet(), scheduledPackages: value }], stops, receipt().pallets), /箱数/)
|
||||
assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: value }], stops, receipt().pallets), /箱数/)
|
||||
}
|
||||
assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: '2' }], stops, receipt().pallets), /核平/)
|
||||
})
|
||||
|
||||
test('unready actual dispatch prevents load confirmation rather than bypassing it with test values', async () => {
|
||||
const calls = []
|
||||
const context = { request: async (method, path) => { calls.push({ method, path }); return { http: 200, body: { code: '200', data: { blockReason: '拣货未完成' } } } },
|
||||
apiSuccess(_module, _name, response) { return response.body.data } }
|
||||
await assert.rejects(confirmFleetLoading(context, 'test', { tripNo: 'TR-1', stops }), /整车未就绪/)
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
})
|
||||
|
||||
function reassignmentServer(fault = '') {
|
||||
const proof = { tripNo: 'TR-FRESH', dispatchNo: 'DP-A', vehicleId: '101', driverId: '201', stops,
|
||||
reassignment: { status: 'PENDING' } }
|
||||
const trip = { tripNo: proof.tripNo, recordVersion: 4, vehicleId: '101', driverId: '201', vehiclePlate: 'ORIGINAL',
|
||||
driverName: 'Original driver', driverPhone: '123', status: 'PLANNED', stops, maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
|
||||
const dispatch = { dispatchNo: proof.dispatchNo, overseasWarehouseId: '92', status: 'READY_TO_DEPART',
|
||||
packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, remainingPackageCount: 0,
|
||||
estimatedArrivalAt: '2026-10-03T12:00:00', items: [{ id: '9007199254740995', inboundCode: 'IN-TEST', packageCount: 10 }],
|
||||
vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone }
|
||||
const calls = [], assertions = [], dispatches = [{ dispatchNo: proof.dispatchNo, customerId: 'A', review: { status: 'APPROVED' } }]
|
||||
let load = null, currentCargo = [cargo()], assigned = false
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: fault === 'string-versions'
|
||||
? JSON.parse(JSON.stringify(data, (key, value) => key === 'recordVersion' ? String(value) : value)) : structuredClone(data) } })
|
||||
if (fault === 'string-versions') trip.recordVersion = 8
|
||||
const loading = () => ({ recordVersion: trip.recordVersion, manifestHash: 'a'.repeat(64), cargo: currentCargo, confirmedLoad: load, blockReason: null })
|
||||
function server(fault = '') {
|
||||
const calls = [], assertions = [], saved = new Map(), loadingActors = new Map()
|
||||
const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998',
|
||||
counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {},
|
||||
dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} }
|
||||
const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops }
|
||||
let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail
|
||||
let mutatedItems = false, mutatedLoading = false
|
||||
const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']])
|
||||
const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n)
|
||||
const result = (action, body, extra = {}) => ({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion,
|
||||
action, idempotencyKey: body.idempotencyKey, result: extra, replayed: false, counts: structuredClone(detail.counts) })
|
||||
const remember = (action, body, extra) => { const r = result(action, body, extra); saved.set(body.idempotencyKey, { body: structuredClone(body), result: structuredClone(r) }); return ok(r) }
|
||||
const context = {
|
||||
marker: 'TEST-REASSIGN',
|
||||
marker: 'TEST-NATIVE', createdResources: {},
|
||||
checkpoint: async () => { if (checkpointError) throw new Error('checkpoint failed') },
|
||||
assert(_module, label, _response, passed) { assertions.push({ label, passed }) },
|
||||
apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null },
|
||||
apiSuccess(_module, _label, response) { return response?.body?.code === '200' ? response.body.data : null },
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path }); assert.equal(file.type, 'image/png')
|
||||
return ok({ licenseFileId: 'FILE-NEW', recordVersion: 1 })
|
||||
calls.push({ method, path, file: { name: file.name, size: file.content.byteLength } })
|
||||
assert.equal(file.type, 'image/png'); assert.match(file.name, /TEST-LICENSE/)
|
||||
uploadComplete = true; driverVersion++
|
||||
return ok({ id: DRIVER, licenseFileId: '9007199254740999', recordVersion: driverVersion })
|
||||
},
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (path.endsWith('/loading')) {
|
||||
if (method === 'GET') return ok(loading())
|
||||
if (String(body.recordVersion) !== String(trip.recordVersion) && fault !== 'accept-old-version') {
|
||||
if (fault === 'reject-but-write') load = { manifestHash: body.manifestHash, pallets: body.pallets }
|
||||
return { http: 200, body: { code: 'A0409', message: fault === 'unrelated-conflict' ? '权限未配置' : '车次已被修改,请刷新后重试' } }
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' })
|
||||
if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown')
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase()
|
||||
return ok({ id: VID })
|
||||
}
|
||||
if (path.endsWith('/fleet/drivers')) return ok({ id: DRIVER, recordVersion: 0 })
|
||||
if (path.endsWith(`/drivers/${DRIVER}/review`)) {
|
||||
assert.ok(uploadComplete); assert.equal(body.recordVersion, driverVersion); driverReviewed = true
|
||||
return ok({ id: DRIVER, reviewStatus: 'APPROVED', recordVersion: ++driverVersion })
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) {
|
||||
const rows = fault === 'missing-trip' ? [] : [trip, ...(nextTrip ? [nextTrip] : [])]
|
||||
if (fault === 'duplicate-trip') rows.push({ ...trip, tripNo: 'TR-B' })
|
||||
const wrong = { 'wrong-plate': { vehiclePlate: `${trip.vehiclePlate}-OTHER` },
|
||||
'wrong-vehicle': { vehicleId: 'OTHER-VEHICLE' }, 'wrong-driver': { driverId: 'OTHER-DRIVER' },
|
||||
'wrong-warehouse': { warehouseId: 'OTHER-WAREHOUSE' } }[fault]
|
||||
if (wrong) rows[0] = { ...rows[0], ...wrong }
|
||||
return ok({ records: rows, page: 1, size: 200, total: rows.length, pages: 1 })
|
||||
}
|
||||
if (method === 'GET' && path === D) return ok(detail)
|
||||
if (method === 'GET' && path === `${D.replace('DP-A', 'DP-NEXT')}`) return ok(nextDetail)
|
||||
if (method === 'GET' && path === `${D.replace('DP-A', 'DP-NEXT')}/items`) return ok({ dispatchNo: 'DP-NEXT', recordVersion: nextDetail.recordVersion, items: [] })
|
||||
if (method === 'POST' && path === D.replace('/DP-A', '')) {
|
||||
assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, [])
|
||||
assert.equal(body.vehicle.vehicleId, VID); assert.equal(body.driver.driverId, DRIVER)
|
||||
assert.equal(claims.size, 0)
|
||||
if (fault === 'unknown-next-create') throw new Error('next create unknown')
|
||||
nextTrip = { ...trip, tripNo: 'TR-NEXT', status: 'PLANNED', recordVersion: 0, vehicleRelease: null, driverRelease: null }
|
||||
nextDetail = { dispatchNo: 'DP-NEXT', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0,
|
||||
counts: { itemCount: 0, palletCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 } }
|
||||
if (fault === 'new-empty-has-cargo') nextDetail.counts.itemCount = 1
|
||||
claims.set('VEHICLE', 'TR-NEXT'); claims.set('DRIVER', 'TR-NEXT')
|
||||
return ok({ ...nextDetail, result: { dispatchNo: 'DP-NEXT' } })
|
||||
}
|
||||
if (method === 'POST' && path === `${D.replace('DP-A', 'DP-NEXT')}/cancel`) {
|
||||
assert.equal(body.recordVersion, nextDetail.recordVersion)
|
||||
nextDetail.status = 'CANCELLED'; nextDetail.recordVersion++; claims.clear()
|
||||
return ok(nextDetail)
|
||||
}
|
||||
if (method === 'GET' && path === `${D}/items`) return ok({ dispatchNo: 'DP-A', recordVersion: detail.recordVersion, items: [{ warehousePalletId: PALLET, pickedPackages: detail.counts.pickedPackageCount, customerId: mutatedItems ? 'OTHER-CUSTOMER' : 'CUSTOMER-A' }] })
|
||||
if (method === 'GET' && [D, P].some(prefix => path === `${prefix}/loading`)) {
|
||||
return ok({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion, pickingReconciled: detail.counts.pickedPackageCount === 3,
|
||||
manifestHash: mutatedLoading ? 'CHANGED-MANIFEST' : 'REAL-MANIFEST', loadingComplete: { allowed: detail.counts.pickedPackageCount === 3, blockReason: '拣货未核平' },
|
||||
pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] })
|
||||
}
|
||||
if (method === 'PUT' && path === D) {
|
||||
if (detail.stage !== 'BEFORE_LOADING') {
|
||||
if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount--
|
||||
return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派')
|
||||
}
|
||||
load = { manifestHash: body.manifestHash, pallets: body.pallets, cargo: currentCargo }
|
||||
if (assigned && fault === 'wrong-final-measurement') load.pallets = [{ ...body.pallets[0], weightKg: 200 }]
|
||||
trip.recordVersion++; return ok(loading())
|
||||
assert.ok(driverReviewed); assert.equal(body.recordVersion, detail.recordVersion)
|
||||
assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.vehicle.vehicleId, VID)
|
||||
assert.equal(body.driver.driverId, DRIVER); assert.ok(body.driver.evidence.expectedTaskEndAt)
|
||||
detail.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: VID, plate: nextPlate, carrierName: 'TEST自有车', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } },
|
||||
driver: { mode: 'MASTER', driverId: DRIVER, name: 'TEST司机', phone: '19900000000' } }
|
||||
Object.assign(trip, { vehicleId: VID, vehiclePlate: nextPlate, driverId: DRIVER })
|
||||
bump(); return remember('EDIT', body)
|
||||
}
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE'); assert.equal(body.maxLoadKg, trip.maxLoadKg)
|
||||
return ok({ id: '102' })
|
||||
if (method === 'POST' && path === `${D}/return-to-review`) {
|
||||
assert.equal(detail.counts.pickedPackageCount, 0); assert.equal(body.recordVersion, detail.recordVersion)
|
||||
detail.stage = 'BEFORE_LOADING'; detail.reviewState = 'DRAFT'; bump(); return remember('RETURN_TO_REVIEW', body)
|
||||
}
|
||||
if (path.endsWith('/drivers')) return ok({ id: '202', recordVersion: 0 })
|
||||
if (path.includes('/dispatches/') && path.endsWith('/review')) {
|
||||
assert.equal(String(body.recordVersion), String(trip.recordVersion)); trip.recordVersion++
|
||||
dispatches[0].review = { status: 'APPROVED' }; return ok({ trip, dispatches })
|
||||
if (method === 'POST' && ['submit-review', 'dispatch-review', 'loading-review'].some(action => path === `${D}/${action}`)) {
|
||||
assert.equal(body.recordVersion, detail.recordVersion); assert.match(body.idempotencyKey, /^[a-f0-9]{64}$/)
|
||||
if (path.endsWith('/submit-review')) { detail.reviewState = 'DISPATCH_PENDING'; detail.reviews.dispatchReview = { status: 'PENDING' } }
|
||||
else if (path.endsWith('/dispatch-review')) { assert.equal(detail.reviewState, 'DISPATCH_PENDING'); detail.reviewState = 'LOADING_PENDING'; detail.reviews.dispatchReview.status = 'APPROVED' }
|
||||
else { assert.equal(detail.reviewState, 'LOADING_PENDING'); detail.reviewState = 'APPROVED'; detail.stage = 'LOADING'; detail.reviews.loadingReview = { status: 'APPROVED' } }
|
||||
bump(); return remember('REVIEW', body)
|
||||
}
|
||||
if (path.endsWith('/loading-review')) {
|
||||
assert.equal(String(body.recordVersion), String(trip.recordVersion)); assert.ok(load); trip.recordVersion++
|
||||
return ok({ trip, dispatches, loadingReview: { status: 'APPROVED' } })
|
||||
if (method === 'POST' && [D, P].some(prefix => path === `${prefix}/loading-complete`)) {
|
||||
if (saved.has(body.idempotencyKey)) {
|
||||
assert.deepEqual(body, saved.get(body.idempotencyKey).body)
|
||||
if (loadingActors.get(body.idempotencyKey) !== (token ? 'PDA' : 'PLATFORM')) {
|
||||
if (fault === 'cross-actor-unknown') throw new Error('cross actor result unknown')
|
||||
if (fault === 'cross-actor-mutates-main') bump()
|
||||
if (fault === 'cross-actor-mutates-items') mutatedItems = true
|
||||
if (fault === 'cross-actor-mutates-loading') mutatedLoading = true
|
||||
if (fault !== 'cross-actor-accepted') return { http: 200, body: { code: 'A0403',
|
||||
message: fault === 'cross-actor-generic-forbidden' ? '没有装车权限' : '该请求标识不属于当前操作员' } }
|
||||
}
|
||||
const replay = { ...saved.get(body.idempotencyKey).result, replayed: true }
|
||||
if (fault === 'replay-new-version') replay.recordVersion = String(BigInt(replay.recordVersion) + 1n)
|
||||
return ok(replay)
|
||||
}
|
||||
assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3)
|
||||
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 } })
|
||||
}
|
||||
if (path.endsWith('/review')) return ok({ reviewStatus: 'APPROVED' })
|
||||
if (path.endsWith('/assignment')) {
|
||||
assert.equal(String(body.recordVersion), String(trip.recordVersion))
|
||||
Object.assign(trip, { vehicleId: body.vehicleId, driverId: body.driverId, vehiclePlate: 'REPLACEMENT', driverName: 'Replacement driver', driverPhone: '456' })
|
||||
trip.recordVersion++; assigned = true; dispatches[0].review = { status: 'PENDING' }
|
||||
if (fault !== 'keeps-old-load') load = null
|
||||
Object.assign(dispatch, { vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone })
|
||||
if (fault === 'changes-picking') dispatch.pickedPackageCount = 0
|
||||
if (fault === 'changes-customer') currentCargo = [{ ...cargo(), customerId: 'B' }]
|
||||
if (fault === 'unknown-assignment-outcome') throw new Error('connection interrupted after commit')
|
||||
return ok({ trip, dispatches })
|
||||
if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) {
|
||||
const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind')
|
||||
return ok([{ resourceId: kind === 'VEHICLE' ? VID : DRIVER, occupiedTripNo: claims.get(kind) ?? null, available: true }])
|
||||
}
|
||||
if (path.endsWith(`/dispatches/${proof.dispatchNo}`)) return ok(dispatch)
|
||||
if (path.endsWith(`/trips/${proof.tripNo}`)) return ok({ trip, dispatches })
|
||||
throw new Error(`unexpected request ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
return { proof, calls, assertions, context, trip }
|
||||
}
|
||||
|
||||
test('fresh-run load checks reassignment, unchanged work, stale version rejection and fresh confirmation before returning departure version', async () => {
|
||||
const server = reassignmentServer()
|
||||
const version = await confirmFleetLoading(server.context, 'test', server.proof)
|
||||
assert.equal(server.proof.reassignment.status, 'VERIFIED')
|
||||
assert.equal(version, 9)
|
||||
assert.equal(server.proof.vehicleId, '102'); assert.equal(server.proof.driverId, '202')
|
||||
assert.equal(server.proof.reassignment.originalTrip.vehicleId, '101')
|
||||
assert.equal(server.proof.reassignment.dispatchAfter.pickedPackageCount, 10)
|
||||
assert.equal(server.proof.confirmedLoad.pallets[0].weightKg, 100)
|
||||
assert.ok(server.assertions.every(row => row.passed))
|
||||
assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).length, 3)
|
||||
assert.ok(server.calls.every(row => !/\/depart|\/pick|\/complete$/.test(row.path)), 'reassignment does not repeat physical warehouse work')
|
||||
})
|
||||
|
||||
test('string-serialized versions compare numerically across 9 to 10 and remain unchanged in requests', async () => {
|
||||
const server = reassignmentServer('string-versions')
|
||||
assert.equal(await confirmFleetLoading(server.context, 'test', server.proof), '13')
|
||||
assert.equal(server.proof.reassignment.status, 'VERIFIED')
|
||||
assert.equal(server.proof.reassignment.assignmentRequest.recordVersion, '9')
|
||||
assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).at(-1).body.recordVersion, '11')
|
||||
})
|
||||
|
||||
for (const [fault, message] of [
|
||||
['keeps-old-load', /原装载失效/], ['changes-picking', /保留拣货/], ['changes-customer', /客户归属/],
|
||||
['accept-old-version', /旧版本不能/], ['unrelated-conflict', /旧版本不能/],
|
||||
['reject-but-write', /拒绝后仍未确认/], ['wrong-final-measurement', /装载确认返回/],
|
||||
['unknown-assignment-outcome', /connection interrupted/],
|
||||
]) test(`reassignment stops on ${fault} and retains requests without silently retrying`, async () => {
|
||||
const server = reassignmentServer(fault)
|
||||
await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), message)
|
||||
assert.equal(server.proof.reassignment.status, 'CHECKING')
|
||||
assert.equal(server.proof.reassignment.assignmentRequest.vehicleId, '102')
|
||||
const callCount = server.calls.length
|
||||
await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), /已开始/)
|
||||
assert.equal(server.calls.length, callCount)
|
||||
})
|
||||
|
||||
test('retained dispatch loading does not create replacement resources or mutate assignment', async () => {
|
||||
const server = reassignmentServer()
|
||||
delete server.proof.reassignment
|
||||
const version = await confirmFleetLoading(server.context, 'test', server.proof)
|
||||
assert.equal(version, 6)
|
||||
assert.equal(server.calls.length, 3)
|
||||
assert.ok(server.calls.every(row => (row.path.endsWith('/loading') || row.path.endsWith('/loading-review'))))
|
||||
})
|
||||
|
||||
function closureServer(fault = '') {
|
||||
const proof = { tripNo: 'TR-ORIGINAL', vehicleId: '9007199254740993', driverId: '9007199254740995', warehouseId: '92', stops }
|
||||
const trips = new Map([[proof.tripNo, { tripNo: proof.tripNo, recordVersion: 5, vehicleId: proof.vehicleId, driverId: proof.driverId,
|
||||
status: 'IN_TRANSIT', completedAt: null, vehicleRelease: null, driverRelease: null }]])
|
||||
const claims = new Map([[`VEHICLE:${proof.vehicleId}`, proof.tripNo], [`DRIVER:${proof.driverId}`, proof.tripNo]])
|
||||
const remembered = new Map(), calls = [], assertions = []
|
||||
let sequence = 0
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } })
|
||||
const fail = message => ({ http: 200, body: { code: 'A0409', message } })
|
||||
const context = {
|
||||
marker: 'CONTROLLED-TEST',
|
||||
assert(_module, label, _response, passed) { assertions.push({ label, passed }) },
|
||||
apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (path.endsWith('/master-data/vehicles')) {
|
||||
assert.equal(body.status, 'IN_SERVICE', 'the independent driver occupancy probe needs a valid in-service vehicle')
|
||||
return ok({ id: 'SPARE-VEHICLE' })
|
||||
}
|
||||
if (method === 'POST' && path.endsWith('/fleet/trips')) {
|
||||
const tripNo = `TR-PROBE-${++sequence}`
|
||||
const trip = { tripNo, recordVersion: 0, status: 'PLANNED', vehicleId: null, driverId: null }
|
||||
trips.set(tripNo, trip); return ok({ trip })
|
||||
}
|
||||
const match = path.match(/\/trips\/([^/]+)(.*)$/), trip = trips.get(match?.[1]), suffix = match?.[2]
|
||||
assert.ok(trip, `unexpected request ${path}`)
|
||||
if (method === 'GET' && suffix === '/completion') return ok({ recordVersion: trip.recordVersion, canComplete: true, outcomes: [cargo()] })
|
||||
if (method === 'GET' && !suffix) return ok({ trip })
|
||||
if (suffix === '/complete' || suffix === '/resources/release') {
|
||||
const key = `${trip.tripNo}:${body.idempotencyKey}`
|
||||
if (remembered.has(key)) {
|
||||
assert.deepEqual(body, remembered.get(key), 'retry must preserve exact original body')
|
||||
if (fault === 'old-release-clears-new-claim' && suffix === '/resources/release') claims.delete(`${body.resourceKind}:${body.resourceKind === 'VEHICLE' ? trip.vehicleId : trip.driverId}`)
|
||||
if (method === 'GET' && path === `${F}/completion`) return ok({ tripNo: 'TR-A', recordVersion: trip.recordVersion, canComplete: fault !== 'incomplete', outcomes: [{ deliveryNo: 'DL-A', packages: 3 }] })
|
||||
if (method === 'GET' && path === F) return ok({ trip, dispatches: fault === 'wrong-binding' ? [{ dispatchNo: 'DP-OTHER' }] : [{ dispatchNo: 'DP-A' }] })
|
||||
if (method === 'GET' && path === F.replace('TR-A', 'TR-B')) return ok({ trip: { ...trip, tripNo: 'TR-B' }, dispatches: [{ dispatchNo: 'DP-A' }] })
|
||||
if (method === 'GET' && path === F.replace('TR-A', 'TR-NEXT')) return ok({ trip: nextTrip, dispatches: [{ dispatchNo: 'DP-NEXT' }] })
|
||||
if (method === 'POST' && [ `${F}/complete`, `${F}/resources/release` ].includes(path)) {
|
||||
if (saved.has(body.idempotencyKey)) {
|
||||
assert.deepEqual(body, saved.get(body.idempotencyKey))
|
||||
if (fault === 'old-release-clears-new-claim' && nextTrip && path.endsWith('/resources/release')) claims.delete(body.resourceKind)
|
||||
return ok({ trip })
|
||||
}
|
||||
assert.equal(body.recordVersion, trip.recordVersion)
|
||||
remembered.set(key, structuredClone(body)); trip.recordVersion++
|
||||
if (suffix === '/complete') { trip.status = 'COMPLETED'; trip.completedAt = '2026-10-02T00:00:00Z' }
|
||||
else {
|
||||
const vehicle = body.resourceKind === 'VEHICLE'
|
||||
claims.delete(`${body.resourceKind}:${vehicle ? trip.vehicleId : trip.driverId}`)
|
||||
trip[vehicle ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation, vehicleStatus: body.vehicleStatus }
|
||||
if (fault === 'vehicle-also-frees-driver' && vehicle) claims.delete(`DRIVER:${trip.driverId}`)
|
||||
assert.equal(body.recordVersion, trip.recordVersion); saved.set(body.idempotencyKey, structuredClone(body)); trip.recordVersion++
|
||||
if (path.endsWith('/complete')) {
|
||||
trip.status = 'COMPLETED'; trip.completedAt = '2026-10-19T00:00:00Z'
|
||||
if (fault === 'completion-auto-releases') { claims.clear(); trip.vehicleRelease = { actualEndAt: trip.completedAt } }
|
||||
} else {
|
||||
claims.delete(body.resourceKind)
|
||||
trip[body.resourceKind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation }
|
||||
if (fault === 'vehicle-frees-driver' && body.resourceKind === 'VEHICLE') claims.delete('DRIVER')
|
||||
}
|
||||
return ok({ trip })
|
||||
}
|
||||
if (suffix === '/assignment') {
|
||||
if (fault === 'unrelated-conflict') return fail('车次版本已变化')
|
||||
if (claims.has(`VEHICLE:${body.vehicleId}`)) return fail('车辆已被其他车次占用')
|
||||
if (claims.has(`DRIVER:${body.driverId}`)) return fail('司机已被其他车次占用')
|
||||
assert.equal(body.recordVersion, trip.recordVersion)
|
||||
trip.vehicleId = body.vehicleId; trip.driverId = body.driverId; trip.recordVersion++
|
||||
claims.set(`VEHICLE:${body.vehicleId}`, trip.tripNo); claims.set(`DRIVER:${body.driverId}`, trip.tripNo)
|
||||
return ok({ trip })
|
||||
}
|
||||
if (suffix === '/cancel') {
|
||||
assert.equal(body.recordVersion, trip.recordVersion)
|
||||
for (const [key, owner] of claims) if (owner === trip.tripNo) claims.delete(key)
|
||||
trip.status = 'CANCELLED'; trip.recordVersion++; return ok({ trip })
|
||||
}
|
||||
throw new Error(`unhandled request ${method} ${path}`)
|
||||
assert.fail(`retired or unexpected request ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
return { proof, trips, claims, calls, assertions, context }
|
||||
return { context, detail, trip, calls, assertions, claims, pick() { detail.counts.pickedPackageCount = 3; bump() }, failCheckpoint() { checkpointError = true } }
|
||||
}
|
||||
|
||||
test('closure proves independent resource occupancy through real command-shaped probes, then cancels empty plans', async () => {
|
||||
const server = closureServer()
|
||||
const result = await finishFleetTrip(server.context, 'test', server.proof)
|
||||
assert.equal(result.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
assert.equal(server.claims.size, 0)
|
||||
assert.ok(server.assertions.every(row => row.passed))
|
||||
assert.equal(result.probes.length, 2)
|
||||
assert.ok(result.probes.every(row => row.finalStatus === 'CANCELLED'))
|
||||
assert.equal(server.calls.filter(row => row.path.endsWith('/resources/release')).length, 4)
|
||||
assert.equal(server.calls.filter(row => row.path.endsWith('/assignment')).length, 5)
|
||||
assert.ok(server.calls.every(row => !/\/depart|\/items\/.*\/pick/.test(row.path)), 'closure never replays physical departure or inventory work')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /已开始/)
|
||||
const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92')
|
||||
|
||||
function jacksonCountWire(s) {
|
||||
const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages'])
|
||||
const serialize = value => Array.isArray(value) ? value.map(serialize)
|
||||
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([name, nested]) =>
|
||||
[name, names.has(name) && typeof nested === 'number' ? String(nested) : serialize(nested)])) : value
|
||||
const original = s.context.request
|
||||
s.context.request = async (...args) => serialize(await original(...args))
|
||||
return s
|
||||
}
|
||||
|
||||
test('real string-zero counts permit preparation, PDA loading replay and safe empty-dispatch closure', async () => {
|
||||
const s = jacksonCountWire(server()), proof = await prepare(s)
|
||||
assert.equal(proof.preparation, 'VERIFIED')
|
||||
s.pick()
|
||||
await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt())
|
||||
s.trip.status = 'IN_TRANSIT'
|
||||
const closure = await finishFleetTrip(s.context, 'native', proof)
|
||||
assert.equal(closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED')
|
||||
assert.ok(s.assertions.every(row => row.passed))
|
||||
})
|
||||
|
||||
test('closure fails if ending only the vehicle silently releases the driver too', async () => {
|
||||
const server = closureServer('vehicle-also-frees-driver')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /司机仍不能/)
|
||||
assert.equal(server.proof.closure.status, 'CHECKING')
|
||||
assert.ok(server.assertions.some(row => row.passed === false))
|
||||
test('missing or malformed zero counts cannot create resources before preparation', async () => {
|
||||
for (const value of [undefined, null, false, '', '00', '0.0', '0e0', '9007199254740993']) {
|
||||
const s = server(); s.detail.counts.pickedPackageCount = value
|
||||
await assert.rejects(prepare(s), /箱数/)
|
||||
assert.deepEqual(s.calls.map(row => row.method), ['GET'])
|
||||
}
|
||||
})
|
||||
|
||||
test('closure detects an old release command deleting a subsequent trip claim', async () => {
|
||||
const server = closureServer('old-release-clears-new-claim')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /旧结束重放不能释放/)
|
||||
assert.notEqual(server.proof.closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
|
||||
test('native preparation edits existing master then submits both reviews, discovers the real sole automatic Fleet record', async () => {
|
||||
const s = server(), proof = await prepare(s)
|
||||
assert.equal(proof.tripNo, 'TR-A'); assert.equal(proof.preparation, 'VERIFIED'); assert.equal(proof.vehicleId, VID)
|
||||
assert.equal(proof.vehicle.plate, proof.marker.toUpperCase()); assert.equal(proof.driver.phone, '19900000000')
|
||||
assert.equal(proof.recordVersion, '9007199254741002'); assert.equal(proof.fleetRecordVersion, 4)
|
||||
const writes = s.calls.filter(c => ['POST', 'PUT'].includes(c.method))
|
||||
assert.deepEqual(writes.slice(-4).map(c => c.path), [D, `${D}/submit-review`, `${D}/dispatch-review`, `${D}/loading-review`])
|
||||
assert.ok(writes.every(c => !/\/trips(?:$|\/.*\/(?:assignment|plan|bind|review))/.test(c.path)))
|
||||
assert.equal(s.detail.dispatchInfo.fbaAppointments[0].appointmentNo, 'FBA-ORIGINAL')
|
||||
assert.ok(s.assertions.every(a => a.passed))
|
||||
})
|
||||
|
||||
test('an unrelated conflict cannot stand in for proof of occupied resources', async () => {
|
||||
const server = closureServer('unrelated-conflict')
|
||||
await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /未证明资源占用/)
|
||||
assert.equal(server.calls.some(row => row.path.endsWith('/resources/release')), false)
|
||||
test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => {
|
||||
const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED'
|
||||
const proof = await prepare(s)
|
||||
const returnIndex = s.calls.findIndex(c => c.path.endsWith('/return-to-review'))
|
||||
assert.ok(returnIndex >= 0 && returnIndex < s.calls.findIndex(c => c.method === 'PUT'))
|
||||
assert.equal(proof.returnRequest.recordVersion, '9007199254740998'); assert.equal(proof.preparation, 'VERIFIED')
|
||||
})
|
||||
|
||||
test('authoritative uppercase vehicle plate discovers both the prepared and subsequent native dispatch without changing marker', async () => {
|
||||
const s = server(), request = s.context.request, marker = 'FT-1d529b3e-d15'
|
||||
s.context.request = async (...args) => {
|
||||
// 固定本轮随机标识以精确复现真实HTTP大小写回执;首次GET仍在任何车辆写入之前。
|
||||
if (args[0] === 'GET' && args[1] === D && !s.context.createdResources.fleetTrips[0].vehicleId)
|
||||
s.context.createdResources.fleetTrips[0].marker = marker
|
||||
return request(...args)
|
||||
}
|
||||
const proof = await prepare(s)
|
||||
assert.equal(proof.marker, marker); assert.equal(proof.vehicle.plate, 'FT-1D529B3E-D15')
|
||||
assert.equal(proof.tripNo, 'TR-A')
|
||||
s.trip.status = 'IN_TRANSIT'
|
||||
const closure = await finishFleetTrip(s.context, 'native', proof)
|
||||
assert.equal(closure.nextDispatch.marker, marker); assert.equal(closure.nextDispatch.vehicle, undefined)
|
||||
assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED')
|
||||
const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?'))
|
||||
assert.equal(queries.length, 2)
|
||||
for (const call of queries) assert.equal(new URL(`https://unused.test${call.path}`).searchParams.get('keyword'), 'FT-1D529B3E-D15')
|
||||
})
|
||||
|
||||
for (const fault of ['missing-trip', 'duplicate-trip', 'wrong-binding', 'wrong-plate', 'wrong-vehicle', 'wrong-driver', 'wrong-warehouse']) test(`automatic Fleet identity fails closed: ${fault}`, async () => {
|
||||
const s = server(fault); await assert.rejects(prepare(s), /唯一|绑定/)
|
||||
assert.equal(s.context.createdResources.fleetTrips[0].preparation, 'CHECKING')
|
||||
})
|
||||
|
||||
test('started picking cannot be silently prepared, reassigned or returned', async () => {
|
||||
const s = server(); s.detail.stage = 'LOADING'; s.detail.counts.pickedPackageCount = 1
|
||||
await assert.rejects(prepare(s), /零扫码/)
|
||||
assert.equal(s.calls.length, 1)
|
||||
})
|
||||
|
||||
test('unknown write result retains the exact command, does not retry or generate another Fleet record', async () => {
|
||||
const s = server('unknown-edit'); await assert.rejects(prepare(s), /unknown/)
|
||||
const last = s.context.createdResources.fleetTripCommands.at(-1)
|
||||
assert.equal(last.status, 'UNKNOWN'); assert.equal(last.path, D)
|
||||
assert.equal(s.calls.filter(c => c.method === 'PUT').length, 1)
|
||||
})
|
||||
|
||||
test('checkpoint failure sends no business write', async () => {
|
||||
const s = server(); s.failCheckpoint(); await assert.rejects(prepare(s), /checkpoint/)
|
||||
assert.deepEqual(s.calls.map(c => c.method), ['GET'])
|
||||
})
|
||||
|
||||
test('actual loading uses authoritative decimal measurements and a proven producer pallet-space measurement', () => {
|
||||
assert.deepEqual(controlledPalletMeasurements([pallet()], stops, receipt().pallets), [
|
||||
{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) test(`missing actual ${field} blocks; billing values cannot substitute`, () => {
|
||||
const row = pallet(), r = receipt(); row[field] = null; delete r.pallets[0][field]
|
||||
row.grossWeightKg = 999; row.billingWeight = 999
|
||||
assert.throws(() => controlledPalletMeasurements([row], stops, r.pallets), /实际测量缺少/)
|
||||
})
|
||||
|
||||
test('suggested measurements, duplicate physical pallets and unpicked cargo are rejected', () => {
|
||||
const row = pallet(); row.evidence[0].actual = false
|
||||
assert.throws(() => controlledPalletMeasurements([row], stops, receipt().pallets), /真实来源/)
|
||||
assert.throws(() => controlledPalletMeasurements([pallet(), pallet()], stops, receipt().pallets), /重复/)
|
||||
assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: 2 }], stops, receipt().pallets), /核平/)
|
||||
assert.throws(() => controlledPalletMeasurements([pallet()], [{ name: 'Origin' }], receipt().pallets), /卸货站/)
|
||||
})
|
||||
|
||||
test('extra or inconsistent producer measurements cannot alter another pallet or authoritative actual weight', () => {
|
||||
const r = receipt(); r.pallets[0].weightKg = 100
|
||||
assert.throws(() => controlledPalletMeasurements([pallet()], stops, r.pallets), /不一致/)
|
||||
assert.throws(() => controlledPalletMeasurements([pallet()], stops, [...receipt().pallets, { warehousePalletId: 'OTHER' }]), /本车以外/)
|
||||
})
|
||||
|
||||
test('PDA original-actor replay restores loading while another platform actor is rejected without mutation', async () => {
|
||||
const s = server(), proof = await prepare(s); s.pick()
|
||||
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')
|
||||
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])
|
||||
assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${P}/loading-complete`, 'PDA'], [`${P}/loading-complete`, 'PDA'], [`${D}/loading-complete`, 'PLATFORM']])
|
||||
assert.equal(proof.loadingCommand.replay.replayed, true); assert.equal(proof.loadingCommand.platformRejection.code, 'A0403')
|
||||
assert.equal(proof.loadingCommand.platformPositiveLoading, 'PENDING_SEPARATE_SCENARIO')
|
||||
assert.deepEqual(proof.loadingCommand.beforeReplay, proof.loadingCommand.afterReplay)
|
||||
assert.equal(proof.loadingCommand.platformReplay, undefined)
|
||||
assert.equal(writes[0].body.measurements[0].weightKg, '187.25')
|
||||
assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path)))
|
||||
const count = s.calls.length
|
||||
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/)
|
||||
assert.equal(s.calls.length, count)
|
||||
})
|
||||
|
||||
test('platform positive loading remains executable in its own scenario with original-actor replay', async () => {
|
||||
const s = server(), proof = await prepare(s); s.pick()
|
||||
await confirmFleetLoading(s.context, 'native', proof, undefined, receipt())
|
||||
const writes = s.calls.filter(c => c.path.endsWith('/loading-complete'))
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const s = server(fault), proof = await prepare(s); s.pick()
|
||||
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败|unknown/)
|
||||
assert.equal(proof.loadingCommand.platformPositiveLoading, undefined)
|
||||
assert.equal(s.calls.filter(c => c.path.endsWith('/loading-complete')).length, 3)
|
||||
if (fault === 'cross-actor-unknown') assert.equal(s.context.createdResources.fleetTripCommands.at(-1).status, 'UNKNOWN')
|
||||
const count = s.calls.length
|
||||
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/)
|
||||
assert.equal(s.calls.length, count)
|
||||
})
|
||||
|
||||
for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`loading verification rejects native drift: ${fault}`, async () => {
|
||||
const s = server(fault), proof = await prepare(s); s.pick()
|
||||
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败/)
|
||||
if (fault === 'edit-mutates-on-rejection') assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false)
|
||||
})
|
||||
|
||||
test('missing actual producer measurement blocks before any edit rejection or loading write', async () => {
|
||||
const s = server(), proof = await prepare(s); s.pick(); const count = s.calls.length
|
||||
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA'), /palletSpaces/)
|
||||
assert.equal(s.calls.length, count + 1); assert.equal(s.calls.at(-1).method, 'GET')
|
||||
})
|
||||
|
||||
test('wrong-run producer receipt and unreconciled picking block loading without mutation', async () => {
|
||||
const s = server(), proof = await prepare(s)
|
||||
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /未就绪/)
|
||||
s.pick(); await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', { ...receipt(), marker: 'OTHER-RUN' }), /不属于本轮/)
|
||||
assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false)
|
||||
})
|
||||
|
||||
test('closure keeps cargo completion, vehicle ending and driver ending distinct using real occupancy owners', async () => {
|
||||
const s = server(), proof = await prepare(s); s.trip.status = 'IN_TRANSIT'
|
||||
const state = await finishFleetTrip(s.context, 'native', proof)
|
||||
assert.equal(state.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'); assert.equal(s.claims.size, 0)
|
||||
assert.equal(state.subsequentTripClaimProtection, 'VERIFIED_NATIVE_EMPTY_DISPATCH')
|
||||
assert.equal(state.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(state.nextDispatch.finalStatus, 'CANCELLED')
|
||||
assert.deepEqual(state.occupancy.map(c => [c.kind, c.rows[0].occupiedTripNo]), [
|
||||
['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A'], ['VEHICLE', null], ['DRIVER', 'TR-A'], ['VEHICLE', null], ['DRIVER', null],
|
||||
['VEHICLE', 'TR-NEXT'], ['DRIVER', 'TR-NEXT'], ['VEHICLE', 'TR-NEXT'], ['DRIVER', 'TR-NEXT'], ['VEHICLE', null], ['DRIVER', null],
|
||||
])
|
||||
assert.equal(s.calls.filter(c => c.path.endsWith('/resources/release')).length, 4)
|
||||
assert.ok(s.calls.every(c => !(c.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(c.path))))
|
||||
await assert.rejects(finishFleetTrip(s.context, 'native', proof), /已开始/)
|
||||
})
|
||||
|
||||
for (const fault of ['incomplete', 'completion-auto-releases', 'vehicle-frees-driver', 'old-release-clears-new-claim', 'new-empty-has-cargo', 'unknown-next-create']) test(`closure does not claim success on independent-resource failure: ${fault}`, async () => {
|
||||
const s = server(fault), proof = await prepare(s); s.trip.status = 'IN_TRANSIT'
|
||||
await assert.rejects(finishFleetTrip(s.context, 'native', proof), /失败|unknown/)
|
||||
assert.equal(proof.closure.status, 'CHECKING')
|
||||
if (['new-empty-has-cargo', 'unknown-next-create'].includes(fault)) assert.equal(s.calls.some(c => c.path.endsWith('/DP-NEXT/cancel')), false)
|
||||
})
|
||||
|
||||
@@ -1,296 +1,338 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
|
||||
const BASE = '/api/platform/v1/last-leg/fleet'
|
||||
const DISPATCH = '/api/platform/v1/last-leg/dispatches'
|
||||
const PDA = '/api/pda/v1/last-leg/dispatches'
|
||||
const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const id = value => {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('服务端资源ID超出安全整数范围,必须保留原始字符串')
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
const key = (proof, action) => createHash('sha256').update(`${proof.marker}:${proof.dispatchNo}:${action}`).digest('hex')
|
||||
const version = value => {
|
||||
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value)))
|
||||
throw new Error('服务端车次版本无效')
|
||||
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw new Error('服务端排车版本无效')
|
||||
return BigInt(String(value))
|
||||
}
|
||||
|
||||
async function command(context, module, method, path, body, label) {
|
||||
const response = await context.request(method, path, body)
|
||||
function check(context, module, label, condition, evidence = '') {
|
||||
context.assert(module, label, null, condition, evidence, OPTIONS)
|
||||
if (!condition) throw new Error(`${module}: ${label}失败`)
|
||||
}
|
||||
// 写前冻结原请求;未知结果停写,不能换键重造或自动重发。
|
||||
async function command(context, module, method, path, body, label, token, expectedRejection) {
|
||||
let entry
|
||||
if (method !== 'GET') {
|
||||
context.createdResources ??= {}; context.createdResources.fleetTripCommands ??= []
|
||||
entry = { method, path, body: structuredClone(body), status: 'PREPARED' }
|
||||
context.createdResources.fleetTripCommands.push(entry); await context.checkpoint?.()
|
||||
}
|
||||
let response
|
||||
try { response = await context.request(method, path, body, token) }
|
||||
catch (error) { if (entry) { entry.status = 'UNKNOWN'; await context.checkpoint?.() }; throw error }
|
||||
if (entry) {
|
||||
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId })
|
||||
await context.checkpoint?.()
|
||||
}
|
||||
if (expectedRejection) {
|
||||
check(context, module, label, response?.http === 200 && String(response?.body?.code) === expectedRejection.code
|
||||
&& response?.body?.message === expectedRejection.message)
|
||||
return { http: response.http, code: response.body.code, message: response.body.message,
|
||||
requestId: response.body.requestId, traceId: response.body.traceId }
|
||||
}
|
||||
const data = context.apiSuccess(module, label, response, '', OPTIONS)
|
||||
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Test-only resources are created through real APIs and retained in the run report. No business SQL. */
|
||||
// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。
|
||||
async function discoverTrip(context, module, proof) {
|
||||
// 正式车辆主档将车牌转为大写;保留原marker及命令键,仅按该权威规则生成精确查询身份。
|
||||
const vehiclePlate = proof.marker.toUpperCase()
|
||||
const rows = [], seen = new Set(); let total
|
||||
for (let page = 1; ; page++) {
|
||||
const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId })
|
||||
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录')
|
||||
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|
||||
|| Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联')
|
||||
total ??= Number(data.total)
|
||||
if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && rows.length < total)) throw new Error('车次分页变化或缺页')
|
||||
for (const row of data.records) {
|
||||
if (!row.tripNo || seen.has(row.tripNo)) throw new Error('车次分页业务号缺失或重复')
|
||||
seen.add(row.tripNo); rows.push(row)
|
||||
}
|
||||
if (rows.length >= total) break
|
||||
}
|
||||
if (rows.length !== total) throw new Error('车次分页总数不一致')
|
||||
const matches = rows.filter(row => row.vehiclePlate === vehiclePlate && id(row.vehicleId) === proof.vehicleId
|
||||
&& id(row.driverId) === proof.driverId && id(row.warehouseId) === proof.warehouseId && row.tripPurpose === 'DELIVERY')
|
||||
const bound = []
|
||||
for (const candidate of matches) {
|
||||
const detail = await command(context, module, 'GET', `${BASE}/trips/${encodeURIComponent(candidate.tripNo)}`, undefined, '核对排车与资源记录唯一绑定')
|
||||
if (detail.trip?.tripNo !== candidate.tripNo || id(detail.trip.vehicleId) !== proof.vehicleId || id(detail.trip.driverId) !== proof.driverId)
|
||||
throw new Error('真实车次候选资源发生变化')
|
||||
if (detail.dispatches?.some(row => row.dispatchNo === proof.dispatchNo)) {
|
||||
if (detail.dispatches.length !== 1) throw new Error('原生排车资源记录仍关联多张子单')
|
||||
bound.push(detail)
|
||||
}
|
||||
}
|
||||
if (bound.length !== 1) throw new Error(`本轮排车自动资源记录不是唯一绑定:${bound.length}`)
|
||||
const [detail] = bound
|
||||
Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion })
|
||||
}
|
||||
|
||||
/** 本轮TEST主档经正式API建立;主单创建时已自动建立Fleet资源记录。 */
|
||||
export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) {
|
||||
if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓')
|
||||
const marker = `FT-${randomUUID().slice(0, 12)}`
|
||||
const proof = { dispatchNo, warehouseId: id(warehouseId), marker, reassignment: { status: 'PENDING' } }
|
||||
context.createdResources.fleetTrips ??= []
|
||||
context.createdResources.fleetTrips.push(proof)
|
||||
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
|
||||
remark: `${context.marker} controlled TEST vehicle`,
|
||||
}, '建立本轮真实车辆档案及容量')
|
||||
proof.vehicleId = id(vehicle.id)
|
||||
await prepareQualifiedDriver(context, module, marker, proof)
|
||||
const plannedDepartureAt = new Date(Date.now() + 3600000).toISOString()
|
||||
const expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString()
|
||||
const createKey = createHash('sha256').update(`${context.marker}:${dispatchNo}`).digest('hex')
|
||||
let detail = await command(context, module, 'POST', `${BASE}/trips`, {
|
||||
warehouseId, plannedDepartureAt, expectedDriverEndAt, idempotencyKey: createKey,
|
||||
stops: [{ name: '测试起运仓' }, { name: '待匹配货物目的地' }], remark: context.marker,
|
||||
}, '创建本轮共享运输车次')
|
||||
proof.tripNo = detail.trip.tripNo
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
detail = await command(context, module, 'POST', `${path}/assignment`, {
|
||||
recordVersion: detail.trip.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '本轮测试派车',
|
||||
}, '正式分配车辆司机并核验证照')
|
||||
detail = await command(context, module, 'POST', `${path}/dispatches/bind`, {
|
||||
recordVersion: detail.trip.recordVersion, dispatchNo, reason: '关联本轮客户执行单据',
|
||||
}, '将客户排车单关联至车次')
|
||||
const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '读取真实货物目的地匹配键')
|
||||
const destinations = new Map()
|
||||
for (const cargo of loading.cargo || []) {
|
||||
if (!cargo.destinationKey) throw new Error('实际排车货物缺少目的地,不能伪造装载站点')
|
||||
destinations.set(cargo.destinationKey, { name: cargo.destinationLabel, destinationKey: cargo.destinationKey })
|
||||
const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`,
|
||||
syntheticTestMaterials: true, preparation: 'CHECKING', reassignment: { status: 'PENDING' } }
|
||||
context.createdResources.fleetTrips ??= []; context.createdResources.fleetTrips.push(proof)
|
||||
const path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}`
|
||||
let before = await command(context, module, 'GET', path, undefined, '读取原生排车主单版本和阶段')
|
||||
version(before.recordVersion)
|
||||
if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0
|
||||
|| safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核')
|
||||
if (before.stage === 'LOADING') {
|
||||
proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' }
|
||||
await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前')
|
||||
before = await command(context, module, 'GET', path, undefined, '回查显式退回后的审核状态')
|
||||
}
|
||||
if (!destinations.size) throw new Error('实际排车没有货物')
|
||||
detail = await command(context, module, 'PUT', `${path}/plan`, {
|
||||
recordVersion: detail.trip.recordVersion, plannedDepartureAt, expectedDriverEndAt,
|
||||
stops: [{ name: '测试起运仓' }, ...destinations.values()], reason: '按实际货物目的地冻结路线',
|
||||
}, '在拣货前匹配真实卸货站')
|
||||
proof.stops = detail.trip.stops
|
||||
detail = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(dispatchNo)}/review`, {
|
||||
recordVersion: detail.trip.recordVersion, decision: 'APPROVED',
|
||||
}, '拣货前通过本轮客户排车调度审核')
|
||||
proof.dispatchReview = detail.dispatches.find(row => row.dispatchNo === dispatchNo)?.review
|
||||
if (proof.dispatchReview?.status !== 'APPROVED') throw new Error('客户排车调度审核未通过,停止拣货准备')
|
||||
return proof
|
||||
if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState))
|
||||
throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派')
|
||||
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
|
||||
remark: `${context.marker} TEST合成车辆主档,容量不是实载测量`,
|
||||
}, '建立本轮TEST车辆主档及容量')
|
||||
proof.vehicleId = id(vehicle.id)
|
||||
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
|
||||
await prepareQualifiedDriver(context, module, proof)
|
||||
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
|
||||
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } },
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: before.dispatchInfo?.estimatedArrivalAt ?? null,
|
||||
remark: `${context.marker} TEST合成资料正常履约` }
|
||||
let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机')
|
||||
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
|
||||
const body = { recordVersion: result.recordVersion, idempotencyKey: key(proof, action),
|
||||
...(action === 'submit-review' ? { remark: '本轮TEST整车提交审核' } : { decision: 'APPROVED', reason: '本轮TEST整车审核' }) }
|
||||
result = await command(context, module, 'POST', `${path}/${action}`, body, `原生整车${action}`)
|
||||
}
|
||||
const detail = await command(context, module, 'GET', path, undefined, '审核后读取原生资源快照')
|
||||
check(context, module, '两级审核通过才进入装车中', detail.status === 'ACTIVE' && detail.stage === 'LOADING'
|
||||
&& detail.reviewState === 'APPROVED' && detail.reviews?.dispatchReview?.status === 'APPROVED'
|
||||
&& detail.reviews?.loadingReview?.status === 'APPROVED' && safePackageCount(detail.counts?.pickedPackageCount) === 0
|
||||
&& version(detail.recordVersion) === version(result.recordVersion))
|
||||
Object.assign(proof, { vehicleInfo: detail.vehicleInfo, vehicle: detail.vehicleInfo?.vehicle, driver: detail.vehicleInfo?.driver,
|
||||
dispatchReview: detail.reviews.dispatchReview, loadingReview: detail.reviews.loadingReview, recordVersion: detail.recordVersion })
|
||||
await discoverTrip(context, module, proof)
|
||||
proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof
|
||||
}
|
||||
|
||||
async function prepareQualifiedDriver(context, module, marker, proof) {
|
||||
async function prepareQualifiedDriver(context, module, proof) {
|
||||
proof.expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString()
|
||||
let driver = await command(context, module, 'POST', `${BASE}/drivers`, {
|
||||
name: `${marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
||||
licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC',
|
||||
licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
||||
remark: `${context.marker} TEST credential fixture; not a real driving licence`,
|
||||
}, '建立本轮司机证照档案')
|
||||
name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
||||
licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC',
|
||||
licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${context.marker} TEST合成证照资料,不是真实驾驶证`,
|
||||
}, '建立本轮TEST司机档案')
|
||||
proof.driverId = id(driver.id)
|
||||
const uploaded = await context.requestMultipart('POST', `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, {
|
||||
name: `${marker}-TEST-LICENSE.png`, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'),
|
||||
})
|
||||
driver = context.apiSuccess(module, '上传本轮测试证照文件', uploaded, '', OPTIONS)
|
||||
if (!driver?.licenseFileId) throw new Error('测试证照上传失败,不允许伪造已审核资格')
|
||||
if (!proof.driverId) throw new Error('司机主档没有返回真实资源ID')
|
||||
proof.licenseUpload = { path: `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`,
|
||||
name: `${proof.marker}-TEST-LICENSE.png`, syntheticTestMaterials: true, status: 'PREPARED' }
|
||||
await context.checkpoint?.()
|
||||
let uploaded
|
||||
try {
|
||||
uploaded = await context.requestMultipart('POST', proof.licenseUpload.path, { name: proof.licenseUpload.name, type: 'image/png',
|
||||
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') })
|
||||
} catch (error) { proof.licenseUpload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
|
||||
Object.assign(proof.licenseUpload, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code }); await context.checkpoint?.()
|
||||
driver = context.apiSuccess(module, '上传TEST合成证照文件', uploaded, '', OPTIONS)
|
||||
if (uploaded?.http !== 200 || String(uploaded?.body?.code) !== '200' || !driver?.licenseFileId) throw new Error('TEST证照上传失败,不能伪造资格')
|
||||
proof.licenseFileId = id(driver.licenseFileId)
|
||||
const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, {
|
||||
recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅用于本轮受控测试司机',
|
||||
}, '通过正式命令审核测试司机')
|
||||
if (reviewed.reviewStatus !== 'APPROVED') throw new Error('测试司机未审核通过,停止派车验证')
|
||||
recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅本轮TEST合成证照验收',
|
||||
}, '正式审核本轮TEST司机')
|
||||
if (reviewed.reviewStatus !== 'APPROVED') throw new Error('TEST司机未审核通过')
|
||||
}
|
||||
|
||||
export function controlledPalletMeasurements(cargo, stops) {
|
||||
const pallets = new Map()
|
||||
for (const item of cargo) {
|
||||
if (!item.warehousePalletId || !item.palletNo || !item.destinationKey) throw new Error('装载货物缺少真实托盘或目的地')
|
||||
const deliveryIndex = stops.findIndex(stop => stop.destinationKey === item.destinationKey)
|
||||
if (deliveryIndex < 1) throw new Error('货物目的地不在车次路线')
|
||||
const key = id(item.warehousePalletId)
|
||||
if (pallets.has(key) && pallets.get(key).deliveryIndex !== deliveryIndex) throw new Error('混合目的地实物托盘须先拆托')
|
||||
// Controlled test measurement, deliberately separate from source billing/allocated mass.
|
||||
pallets.set(key, { warehousePalletId: key, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex })
|
||||
const decimal = value => String(value).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
|
||||
function evidenceFor(row, field, value) {
|
||||
return row?.evidence?.some(e => e.field === field && e.actual === true && e.sourceType && e.sourceBusinessNo
|
||||
&& Number.isFinite(Date.parse(e.observedAt)) && decimal(e.value) === decimal(value))
|
||||
}
|
||||
function safePackageCount(value) {
|
||||
if ((typeof value !== 'number' && typeof value !== 'string')
|
||||
|| !/^(0|[1-9]\d*)$/.test(String(value)) || !Number.isSafeInteger(Number(value)))
|
||||
throw new Error('实际托盘箱数缺失或精度无效')
|
||||
return Number(value)
|
||||
}
|
||||
/** 保留导出名称供直接调用方定位;任何实际量均须来自实测证据。 */
|
||||
export function controlledPalletMeasurements(pallets, stops, actualPallets = []) {
|
||||
if (!Array.isArray(pallets) || !pallets.length || !Array.isArray(stops)) throw new Error('没有实际装载托盘或真实路线')
|
||||
const supplied = new Map(), seen = new Set()
|
||||
for (const row of actualPallets) {
|
||||
if (!row.warehousePalletId || supplied.has(id(row.warehousePalletId))) throw new Error('生产实测回执托盘缺失或重复')
|
||||
supplied.set(id(row.warehousePalletId), row)
|
||||
}
|
||||
if (!pallets.size) throw new Error('没有实际装载托盘')
|
||||
return [...pallets.values()]
|
||||
const result = pallets.map(row => {
|
||||
const palletId = id(row.warehousePalletId)
|
||||
if (!palletId || !row.palletNo || seen.has(palletId)) throw new Error('实际预览托盘缺失或重复')
|
||||
seen.add(palletId)
|
||||
const scheduled = safePackageCount(row.scheduledPackages), picked = safePackageCount(row.pickedPackages)
|
||||
if (scheduled <= 0 || picked !== scheduled) throw new Error('实际托盘拣货尚未核平')
|
||||
const receipt = supplied.get(palletId), output = { warehousePalletId: palletId }
|
||||
for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) {
|
||||
const source = row[field] == null ? receipt : row, value = source?.[field]
|
||||
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(value)) || !Number.isFinite(Number(value)) || Number(value) <= 0) throw new Error(`实际测量缺少有效${field}`)
|
||||
if (!evidenceFor(source, field, value)) throw new Error(`实际测量${field}缺少真实来源证据`)
|
||||
if (field === 'palletSpaces' && !Number.isSafeInteger(value)) throw new Error('实际托位必须为正整数')
|
||||
if (row[field] != null && receipt?.[field] != null && decimal(row[field]) !== decimal(receipt[field])) throw new Error(`生产回执与预览${field}不一致`)
|
||||
output[field] = value
|
||||
}
|
||||
const index = row.deliveryIndex ?? receipt?.deliveryIndex
|
||||
if (!Number.isSafeInteger(index) || index < 1 || !stops[index]?.destinationKey) throw new Error('实际卸货站缺失或不在车次路线')
|
||||
if (row.deliveryIndex != null && receipt?.deliveryIndex != null && row.deliveryIndex !== receipt.deliveryIndex) throw new Error('生产回执与实际卸货站不一致')
|
||||
output.deliveryIndex = index; return output
|
||||
})
|
||||
if ([...supplied.keys()].some(palletId => !seen.has(palletId))) throw new Error('生产实测回执包含本车以外托盘')
|
||||
return result
|
||||
}
|
||||
|
||||
export async function confirmFleetLoading(context, module, proof) {
|
||||
// 已扫码单只能用原生编辑拒绝证明,不能再走旧Fleet强行改派。
|
||||
async function verifyPreDepartureReassignment(context, module, proof) {
|
||||
const state = proof.reassignment
|
||||
if (state && state.status !== 'PENDING') throw new Error('本轮改派检查已开始;保留原请求排查,不自动重复写入')
|
||||
if (state) state.status = 'CHECKING'
|
||||
const confirmed = await confirmActualLoad(context, module, proof)
|
||||
if (!state) return approveLoading(context, module, proof, confirmed.recordVersion)
|
||||
state.originalConfirmation = confirmed
|
||||
await verifyPreDepartureReassignment(context, module, proof, confirmed)
|
||||
const reconfirmed = await confirmActualLoad(context, module, proof)
|
||||
check(context, module, '重新确认仍覆盖改派前同批货物', reconfirmed.manifestHash === confirmed.manifestHash
|
||||
&& JSON.stringify(reconfirmed.cargo) === JSON.stringify(confirmed.cargo)
|
||||
&& version(reconfirmed.recordVersion) > version(state.assignedTrip.recordVersion))
|
||||
state.finalConfirmation = reconfirmed
|
||||
const reviewedVersion = await approveLoading(context, module, proof, reconfirmed.recordVersion)
|
||||
state.status = 'VERIFIED'
|
||||
return reviewedVersion
|
||||
if (!state) return
|
||||
if (state.status !== 'PENDING') throw new Error('本轮改派拒绝检查已开始;保留原请求排查,不自动重试')
|
||||
state.status = 'CHECKING'
|
||||
const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`
|
||||
state.before = await command(context, module, 'GET', path, undefined, '已扫码拒绝前保存主单')
|
||||
state.itemsBefore = await command(context, module, 'GET', `${path}/items`, undefined, '保存真实扫码与来源')
|
||||
if (state.before.status !== 'ACTIVE' || state.before.stage !== 'LOADING' || safePackageCount(state.before.counts?.pickedPackageCount) <= 0) throw new Error('拒绝检查缺少本轮真实扫码事实')
|
||||
state.request = { ...proof.updateRequest, recordVersion: state.before.recordVersion, idempotencyKey: key(proof, 'SCANNED_EDIT_MUST_REJECT'),
|
||||
plannedDepartureAt: new Date(Date.parse(proof.updateRequest.plannedDepartureAt) + 60000).toISOString(), remark: 'TEST已扫码后编辑资源计划必须拒绝' }
|
||||
await context.checkpoint?.()
|
||||
const response = await context.request('PUT', path, state.request)
|
||||
state.response = { http: response?.http, code: response?.body?.code, message: response?.body?.message }
|
||||
check(context, module, '已扫码原生排车编辑被服务端拒绝', response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code))))
|
||||
state.after = await command(context, module, 'GET', path, undefined, '拒绝后回查主单')
|
||||
state.itemsAfter = await command(context, module, 'GET', `${path}/items`, undefined, '拒绝后回查真实货物')
|
||||
check(context, module, '已扫码拒绝后版本资源货物数量不变', JSON.stringify(state.before) === JSON.stringify(state.after) && JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter))
|
||||
state.status = 'REJECTED_WITHOUT_MUTATION'
|
||||
}
|
||||
|
||||
async function approveLoading(context, module, proof, recordVersion) {
|
||||
const detail = await command(context, module, 'POST', `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading-review`, {
|
||||
recordVersion, decision: 'APPROVED',
|
||||
}, '实际装载确认后通过整车理货审核')
|
||||
proof.loadingReview = detail.loadingReview
|
||||
if (detail.loadingReview?.status !== 'APPROVED') throw new Error('整车理货审核未通过,不允许发车')
|
||||
proof.recordVersion = detail.trip.recordVersion
|
||||
return proof.recordVersion
|
||||
}
|
||||
|
||||
async function confirmActualLoad(context, module, proof) {
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading`
|
||||
const loading = await command(context, module, 'GET', path, undefined, '拣货后读取整车实际装载依据')
|
||||
if (loading.blockReason) throw new Error(`整车未就绪:${loading.blockReason}`)
|
||||
const pallets = controlledPalletMeasurements(loading.cargo, proof.stops)
|
||||
const confirmed = await command(context, module, 'POST', path, {
|
||||
recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets,
|
||||
reason: '受控测试测量:每个实物托盘1托位、100kg、1m³;非生产测量',
|
||||
}, '通过正式命令确认受控测试装载')
|
||||
proof.confirmedLoad = confirmed.confirmedLoad
|
||||
proof.recordVersion = confirmed.recordVersion
|
||||
check(context, module, '装载确认返回当前明细与新版本', confirmed.confirmedLoad?.manifestHash === loading.manifestHash
|
||||
&& version(confirmed.recordVersion) > version(loading.recordVersion)
|
||||
&& JSON.stringify(confirmed.confirmedLoad?.pallets) === JSON.stringify([...pallets].sort((a, b) => a.warehousePalletId.localeCompare(b.warehousePalletId))))
|
||||
return confirmed
|
||||
}
|
||||
|
||||
function dispatchWorkSnapshot(row) {
|
||||
return JSON.stringify({ dispatchNo: row.dispatchNo, warehouseId: row.overseasWarehouseId,
|
||||
status: row.status, packageCount: row.packageCount, pickedPackageCount: row.pickedPackageCount,
|
||||
shippedPackageCount: row.shippedPackageCount, remainingPackageCount: row.remainingPackageCount,
|
||||
estimatedArrivalAt: row.estimatedArrivalAt, items: row.items })
|
||||
}
|
||||
|
||||
async function verifyPreDepartureReassignment(context, module, proof, confirmed) {
|
||||
const state = proof.reassignment
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`
|
||||
const original = await command(context, module, 'GET', path, undefined, '改派前读取车次资源和计划')
|
||||
const before = await command(context, module, 'GET', dispatchPath, undefined, '改派前保留实际拣货与货物明细')
|
||||
check(context, module, '改派仅针对本轮已拣完但未发车的车次', original.trip?.status === 'PLANNED'
|
||||
&& version(original.trip.recordVersion) === version(confirmed.recordVersion) && before.status === 'READY_TO_DEPART'
|
||||
&& before.dispatchNo === proof.dispatchNo && before.pickedPackageCount > 0 && before.shippedPackageCount === 0
|
||||
&& before.pickedPackageCount === before.packageCount && before.items?.length > 0
|
||||
&& id(original.trip.vehicleId) === proof.vehicleId && id(original.trip.driverId) === proof.driverId)
|
||||
state.originalTrip = original.trip; state.dispatchBefore = before
|
||||
const marker = `SWAP-${randomUUID().slice(0, 12)}`
|
||||
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: original.trip.maxPalletSpaces,
|
||||
maxLoadKg: original.trip.maxLoadKg, maxVolumeCbm: original.trip.maxVolumeCbm, remark: `${context.marker} 发车前改派测试`,
|
||||
}, '创建本轮等容量替换车辆')
|
||||
state.replacement = { vehicleId: id(vehicle.id) }
|
||||
await prepareQualifiedDriver(context, module, marker, state.replacement)
|
||||
state.assignmentRequest = { recordVersion: confirmed.recordVersion, vehicleId: state.replacement.vehicleId,
|
||||
driverId: state.replacement.driverId, reason: '已拣完发车前换车换司机并重新核验装载' }
|
||||
const assigned = await command(context, module, 'POST', `${path}/assignment`, state.assignmentRequest, '已拣货车次正式更换车辆和司机')
|
||||
state.assignedTrip = assigned.trip
|
||||
check(context, module, '改派完成且保持路线与客户关联', id(assigned.trip?.vehicleId) === state.replacement.vehicleId
|
||||
&& id(assigned.trip?.driverId) === state.replacement.driverId && assigned.trip.status === 'PLANNED'
|
||||
&& version(assigned.trip.recordVersion) > version(confirmed.recordVersion) && JSON.stringify(assigned.trip.stops) === JSON.stringify(original.trip.stops)
|
||||
&& JSON.stringify(assigned.dispatches.map(({ review, ...row }) => row)) === JSON.stringify(original.dispatches.map(({ review, ...row }) => row))
|
||||
&& assigned.dispatches.every(row => row.review?.status === 'PENDING'))
|
||||
const after = await command(context, module, 'GET', dispatchPath, undefined, '改派后回查实际拣货与货物明细')
|
||||
state.dispatchAfter = after
|
||||
check(context, module, '改派只更新车辆司机并保留拣货货量到达时间', dispatchWorkSnapshot(after) === dispatchWorkSnapshot(before)
|
||||
&& after.vehiclePlate === assigned.trip.vehiclePlate && after.driverName === assigned.trip.driverName && after.driverPhone === assigned.trip.driverPhone)
|
||||
const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '回查改派后原装载确认失效')
|
||||
check(context, module, '原装载失效但货物和客户归属保持一致', loading.confirmedLoad == null
|
||||
&& version(loading.recordVersion) === version(assigned.trip.recordVersion) && loading.manifestHash === confirmed.manifestHash
|
||||
&& JSON.stringify(loading.cargo) === JSON.stringify(confirmed.cargo))
|
||||
state.staleLoadRequest = { recordVersion: confirmed.recordVersion, manifestHash: confirmed.manifestHash,
|
||||
pallets: confirmed.confirmedLoad.pallets, reason: '旧改派前页面提交必须被拒绝' }
|
||||
const stale = await context.request('POST', `${path}/loading`, state.staleLoadRequest)
|
||||
const conflict = stale?.http === 409 || (stale?.http === 200 && ['409', 'A0409'].includes(String(stale?.body?.code)))
|
||||
state.staleLoadResult = { http: stale?.http, code: stale?.body?.code, message: stale?.body?.message }
|
||||
check(context, module, '旧版本不能恢复旧装载确认', conflict && /已被修改/.test(String(stale?.body?.message || '')))
|
||||
const unchanged = await command(context, module, 'GET', `${path}/loading`, undefined, '拒绝旧版本后回查装载未发生写入')
|
||||
check(context, module, '旧版本拒绝后仍未确认且版本保持不变', version(unchanged.recordVersion) === version(assigned.trip.recordVersion)
|
||||
&& unchanged.confirmedLoad == null && unchanged.manifestHash === loading.manifestHash)
|
||||
let reviewed = assigned
|
||||
for (const binding of assigned.dispatches) {
|
||||
reviewed = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, {
|
||||
recordVersion: reviewed.trip.recordVersion, decision: 'APPROVED',
|
||||
}, '换车换司机后重新通过调度审核')
|
||||
if (reviewed.dispatches.find(row => row.dispatchNo === binding.dispatchNo)?.review?.status !== 'APPROVED') throw new Error('改派后调度审核未通过')
|
||||
export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements) {
|
||||
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)}`
|
||||
const preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken)
|
||||
if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true
|
||||
|| preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`)
|
||||
if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker
|
||||
|| actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车')
|
||||
const measurements = controlledPalletMeasurements(preview.pallets, proof.stops, actualMeasurements?.pallets)
|
||||
await verifyPreDepartureReassignment(context, module, proof)
|
||||
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本轮实际读取测量证据,不使用计费重量或固定值' } }
|
||||
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'
|
||||
&& 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
|
||||
&& measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId
|
||||
&& r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg)
|
||||
&& decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex)))
|
||||
proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad
|
||||
const snapshot = async label => ({
|
||||
main: await command(context, module, 'GET', path, undefined, `${label}主单`),
|
||||
items: await command(context, module, 'GET', `${path}/items`, undefined, `${label}货物`),
|
||||
loading: await command(context, module, 'GET', `${path}/loading`, undefined, `${label}装车事实`),
|
||||
})
|
||||
proof.loadingCommand.beforeReplay = await snapshot('装车原键验证前回读')
|
||||
const replay = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原操作员原端原键重放装车', pdaToken)
|
||||
check(context, module, '原操作员装车重放恢复原结果且不增加版本', replay.replayed === true && replay.dispatchNo === confirmed.dispatchNo
|
||||
&& version(replay.recordVersion) === version(confirmed.recordVersion) && JSON.stringify(replay.result) === JSON.stringify(confirmed.result))
|
||||
proof.loadingCommand.replay = replay
|
||||
if (pdaToken) {
|
||||
// 当前场景使用独立平台/PDA操作者;由账本的精确所有者拒绝证明隔离,不能按Token外观猜同一账号。
|
||||
proof.loadingCommand.platformRejection = await command(context, module, 'POST', `${path}/loading-complete`, proof.loadingCommand.body,
|
||||
'另一平台操作员不能重用PDA装车请求标识', undefined, { code: 'A0403', message: '该请求标识不属于当前操作员' })
|
||||
}
|
||||
state.reviewedDispatches = reviewed.dispatches
|
||||
proof.vehicleId = state.replacement.vehicleId; proof.driverId = state.replacement.driverId
|
||||
proof.licenseFileId = state.replacement.licenseFileId
|
||||
proof.loadingCommand.afterReplay = await snapshot('装车原键验证后回读')
|
||||
check(context, module, '原键重放及操作者拒绝后主单货物装车事实不变',
|
||||
JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay)
|
||||
&& version(proof.loadingCommand.afterReplay.main.recordVersion) === version(confirmed.recordVersion))
|
||||
proof.loadingCommand.platformPositiveLoading = pdaToken ? 'PENDING_SEPARATE_SCENARIO' : 'VERIFIED'
|
||||
proof.recordVersion = confirmed.recordVersion; await context.checkpoint?.(); return proof.recordVersion
|
||||
}
|
||||
|
||||
function check(context, module, label, condition, evidence = '') {
|
||||
context.assert(module, label, null, condition, evidence, OPTIONS)
|
||||
if (!condition) throw new Error(`${module}: ${label}失败`)
|
||||
}
|
||||
|
||||
async function expectOccupied(context, module, path, body, resource, label) {
|
||||
const response = await context.request('POST', `${path}/assignment`, body)
|
||||
const conflict = response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code)))
|
||||
const message = String(response?.body?.message || '')
|
||||
const valid = conflict && message.includes(resource) && message.includes('占用')
|
||||
context.assert(module, label, response, valid, `expected occupied ${resource}`, OPTIONS)
|
||||
if (!valid) throw new Error(`${module}: ${label}未证明资源占用;停止并保留本轮资源`)
|
||||
}
|
||||
|
||||
/** Fresh-run closure proof only. Retain all commands on proof for diagnostics; do not repair old fixtures. */
|
||||
/** 货物结案与车辆司机结束分别执行;不再创建退役Fleet空计划作为探针。 */
|
||||
export async function finishFleetTrip(context, module, proof) {
|
||||
if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明')
|
||||
if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造或改写请求')
|
||||
const state = proof.closure = { status: 'CHECKING', probes: [] }
|
||||
const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车所有货物结案条件')
|
||||
if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造')
|
||||
const state = proof.closure = { status: 'CHECKING', occupancy: [] }, path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
|
||||
const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车货物结案条件')
|
||||
check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '')
|
||||
state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' }
|
||||
const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案车次货物')
|
||||
check(context, module, '结案记录原运输完成时间且尚未声明资源释放', completed.trip?.status === 'COMPLETED'
|
||||
&& Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null)
|
||||
const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '同键重放货物结案')
|
||||
check(context, module, '结案重放没有新增版本或改变完成时间', replay.trip?.recordVersion === completed.trip.recordVersion && replay.trip.completedAt === completed.trip.completedAt)
|
||||
|
||||
async function probe(index) {
|
||||
const request = { warehouseId: proof.warehouseId, idempotencyKey: `${proof.tripNo}:RELEASE-PROBE:${index}`,
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
|
||||
stops: proof.stops, remark: '本轮资源占用验收计划,不关联货物或执行发车' }
|
||||
const evidence = { createRequest: request }; state.probes.push(evidence)
|
||||
const created = await command(context, module, 'POST', `${BASE}/trips`, request, `建立资源占用核对计划${index}`)
|
||||
evidence.tripNo = created.trip.tripNo; evidence.recordVersion = created.trip.recordVersion
|
||||
return { evidence, path: `${BASE}/trips/${encodeURIComponent(evidence.tripNo)}` }
|
||||
const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案货物')
|
||||
check(context, module, '结案未自动结束车辆或司机', completed.trip?.status === 'COMPLETED' && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null)
|
||||
const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '原键重放结案')
|
||||
check(context, module, '结案重放不增加版本或改变完成时间', version(replay.trip.recordVersion) === version(completed.trip.recordVersion) && replay.trip.completedAt === completed.trip.completedAt)
|
||||
async function occupancy(kind, expectedOwner) {
|
||||
const resourceId = kind === 'VEHICLE' ? proof.vehicleId : proof.driverId
|
||||
const rows = await command(context, module, 'GET', `${path}/resource-occupancy?${new URLSearchParams({ kind, resourceIds: resourceId })}`, undefined, `读取${kind}独立占用`)
|
||||
state.occupancy.push({ kind, rows })
|
||||
// available对本车为true,必须核对occupiedTripNo的真实拥有者。
|
||||
check(context, module, `${kind}占用拥有者符合独立结束阶段`, rows.length === 1 && id(rows[0].resourceId) === id(resourceId) && (rows[0].occupiedTripNo ?? null) === expectedOwner)
|
||||
}
|
||||
const first = await probe(1)
|
||||
const assign = { recordVersion: first.evidence.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '验证本轮资源占用' }
|
||||
await expectOccupied(context, module, first.path, assign, '车辆', '货物结案后车辆仍不能被另一车次占用')
|
||||
|
||||
await occupancy('VEHICLE', proof.tripNo); await occupancy('DRIVER', proof.tripNo)
|
||||
const endLocation = proof.stops?.at(-1)?.name
|
||||
check(context, module, '测试任务具有明确结束地点', Boolean(endLocation))
|
||||
// These controlled fixtures end at the signed final stop and contain no return-driving task.
|
||||
state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE',
|
||||
actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: 'IN_SERVICE',
|
||||
idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: '受控测试车辆在末站结束,无返程安排' }
|
||||
const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '单独结束测试车辆任务')
|
||||
check(context, module, '只释放车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null)
|
||||
await expectOccupied(context, module, first.path, assign, '司机', '车辆已释放但司机仍不能被另一车次占用')
|
||||
|
||||
state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER',
|
||||
actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: null,
|
||||
idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: '受控测试司机在末站结束,无返程安排' }
|
||||
const driverReleased = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '单独结束测试司机任务')
|
||||
check(context, module, '车辆和司机各自保留真实任务结束记录', Boolean(driverReleased.trip?.vehicleRelease && driverReleased.trip.driverRelease))
|
||||
const assigned = await command(context, module, 'POST', `${first.path}/assignment`, assign, '释放后正式再次分配相同车辆司机')
|
||||
first.evidence.recordVersion = assigned.trip.recordVersion
|
||||
check(context, module, '释放后的两项资源可被下一计划使用', id(assigned.trip?.vehicleId) === id(proof.vehicleId) && id(assigned.trip?.driverId) === id(proof.driverId))
|
||||
const oldVehicleReplay = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '资源再分配后重放旧车辆结束请求')
|
||||
const oldDriverReplay = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '资源再分配后重放旧司机结束请求')
|
||||
check(context, module, '原结束请求重放不增加旧车次版本', oldVehicleReplay.trip.recordVersion === driverReleased.trip.recordVersion && oldDriverReplay.trip.recordVersion === driverReleased.trip.recordVersion)
|
||||
const second = await probe(2)
|
||||
await expectOccupied(context, module, second.path, { ...assign, recordVersion: second.evidence.recordVersion }, '车辆', '旧结束重放不能释放下一计划的车辆占用')
|
||||
// A different free fixture vehicle isolates the driver claim from the vehicle claim.
|
||||
const spare = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
|
||||
plateNumber: `PROBE-${randomUUID().slice(0,12)}`, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} 仅用于本轮司机占用负向验证`,
|
||||
}, '建立司机占用隔离用测试车辆')
|
||||
state.spareVehicleId = id(spare.id)
|
||||
await expectOccupied(context, module, second.path, { ...assign, vehicleId: state.spareVehicleId, recordVersion: second.evidence.recordVersion }, '司机', '旧结束重放不能释放下一计划的司机占用')
|
||||
for (const item of [second, first]) {
|
||||
const cancelled = await command(context, module, 'POST', `${item.path}/cancel`, { recordVersion: item.evidence.recordVersion, reason: '资源占用核对完成,取消无货测试计划' }, '正式取消无货核对计划')
|
||||
check(context, module, '核对计划取消且保留历史', cancelled.trip?.status === 'CANCELLED')
|
||||
item.evidence.finalStatus = cancelled.trip.status
|
||||
}
|
||||
const final = await command(context, module, 'GET', path, undefined, '回查最终车次结案与资源结束记录')
|
||||
check(context, module, '车次结案及两项资源结束保持一致', final.trip?.status === 'COMPLETED' && Boolean(final.trip.vehicleRelease && final.trip.driverRelease))
|
||||
state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'
|
||||
state.finalTrip = final.trip
|
||||
return state
|
||||
if (!endLocation) throw new Error('TEST运输缺少明确末站结束地点')
|
||||
state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt,
|
||||
endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: 'TEST车辆末站任务独立结束,无返程安排' }
|
||||
const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '独立结束TEST车辆任务')
|
||||
check(context, module, '只结束车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null)
|
||||
await occupancy('VEHICLE', null); await occupancy('DRIVER', proof.tripNo)
|
||||
state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt,
|
||||
endLocation, vehicleStatus: null, idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: 'TEST司机末站任务独立结束,无返程安排' }
|
||||
const released = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '独立结束TEST司机任务')
|
||||
check(context, module, '两项资源各自保留结束记录', Boolean(released.trip?.vehicleRelease && released.trip.driverRelease))
|
||||
await occupancy('VEHICLE', null); await occupancy('DRIVER', null)
|
||||
// 正式空排车是新资源拥有者;不恢复已退役的独立Fleet建单或手工关联。
|
||||
state.nextDispatchRequest = { idempotencyKey: key(proof, 'NEXT_NATIVE_DISPATCH'), warehouseId: proof.warehouseId,
|
||||
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
|
||||
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: new Date(Date.now() + 48 * 3600000).toISOString() } },
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null,
|
||||
remark: `${context.marker} TEST本轮空排车验证旧结束键不能释放新占用`, cargo: [], batchAllocations: [] }
|
||||
state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源')
|
||||
if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号')
|
||||
state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId,
|
||||
marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId }
|
||||
await discoverTrip(context, module, state.nextDispatch)
|
||||
if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录')
|
||||
await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo)
|
||||
const oldVehicle = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '原键重放车辆结束')
|
||||
const oldDriver = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '原键重放司机结束')
|
||||
check(context, module, '独立结束重放不增加版本', version(oldVehicle.trip.recordVersion) === version(released.trip.recordVersion) && version(oldDriver.trip.recordVersion) === version(released.trip.recordVersion))
|
||||
await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo)
|
||||
const nextPath = `${DISPATCH}/${encodeURIComponent(state.nextDispatch.dispatchNo)}`
|
||||
state.nextDispatchBeforeCancel = await command(context, module, 'GET', nextPath, undefined, '取消前核齐本轮新空排车当前版本和零实物')
|
||||
state.nextDispatchItems = await command(context, module, 'GET', `${nextPath}/items`, undefined, '取消前核齐本轮新空排车零货物来源')
|
||||
const next = state.nextDispatchBeforeCancel
|
||||
check(context, module, '仅取消本轮已确定且从未执行的原生空单', next.dispatchNo === state.nextDispatch.dispatchNo
|
||||
&& next.status === 'ACTIVE' && next.stage === 'BEFORE_LOADING' && safePackageCount(next.counts?.itemCount) === 0
|
||||
&& safePackageCount(next.counts?.packageCount) === 0 && safePackageCount(next.counts?.pickedPackageCount) === 0 && safePackageCount(next.counts?.shippedPackageCount) === 0
|
||||
&& version(next.recordVersion) === version(state.nextDispatchResult.recordVersion)
|
||||
&& state.nextDispatchItems.dispatchNo === next.dispatchNo && state.nextDispatchItems.items?.length === 0
|
||||
&& version(state.nextDispatchItems.recordVersion) === version(next.recordVersion))
|
||||
state.nextDispatchCancelRequest = { recordVersion: next.recordVersion,
|
||||
idempotencyKey: key(proof, 'CANCEL_NEXT_NATIVE_DISPATCH'), reason: 'TEST原生空排车占用核对完成,保留取消历史' }
|
||||
const cancelled = await command(context, module, 'POST', `${nextPath}/cancel`,
|
||||
state.nextDispatchCancelRequest, '正式取消本轮未执行空排车')
|
||||
check(context, module, '空排车取消并保留历史', cancelled.dispatchNo === state.nextDispatch.dispatchNo && cancelled.status === 'CANCELLED')
|
||||
state.nextDispatch.finalStatus = cancelled.status
|
||||
await occupancy('VEHICLE', null); await occupancy('DRIVER', null)
|
||||
state.finalTrip = (await command(context, module, 'GET', path, undefined, '回查结案及资源结束记录')).trip
|
||||
check(context, module, '最终结案和资源结束保持一致', state.finalTrip?.status === 'COMPLETED' && Boolean(state.finalTrip.vehicleRelease && state.finalTrip.driverRelease))
|
||||
state.subsequentTripClaimProtection = 'VERIFIED_NATIVE_EMPTY_DISPATCH'
|
||||
state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'; await context.checkpoint?.(); return state
|
||||
}
|
||||
|
||||
@@ -1,93 +1,134 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
import { DISPATCH_BASE as BASE, verifyGroupedDispatch, groupedDispatchMatches, createBody, dispatchIo, sourceSelection, version, versionAfter } from './grouped-dispatch-acceptance.mjs'
|
||||
|
||||
const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`,
|
||||
overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n }))
|
||||
const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] }))
|
||||
const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId,
|
||||
dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1,
|
||||
packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0,
|
||||
items: [{ ...candidates[n], id: `7${n}` }] } }))
|
||||
test('registered dispatch flow consumes current-run selected candidates before its main draft', () => {
|
||||
const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8')
|
||||
const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)')
|
||||
assert.ok(call > source.indexOf('const selectedCandidates'))
|
||||
assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`'))
|
||||
const rows = () => [1, 2].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) },
|
||||
sourceKey: `PLAN:2${n}`, sourceVersion: `${n}`.repeat(64), customerId: '55', warehouseId: '44', inboundCode: `IN-${n}`, palletNo: `P-${n}`,
|
||||
destination: { key: `FBA:A${n}`, type: 'FBA', warehouseCode: `A${n}` }, availablePackages: n, appointmentBatches: [], blockReasons: [] }))
|
||||
function server(fault = '') {
|
||||
const candidates = rows(), drafts = new Map(), orders = new Map(), ledger = new Map(), calls = [], checks = []
|
||||
let serial = 0, containerId = 60, itemId = 70
|
||||
const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' })
|
||||
const context = { marker: 'CURRENT', createdResources: { phase4OverseasPda: { marker: 'CURRENT', finalStatus: 'COMPLETED', customerId: '55', overseasWarehouseId: '44', palletIds: ['31', '32'] } },
|
||||
checkpoint: async () => {}, // Explicit offline persistence double; production run supplies atomic disk persistence.
|
||||
assert: (_module, label, _response, passed) => checks.push({ label, passed }),
|
||||
async request(method, raw, body) {
|
||||
calls.push({ method, path: raw, body: structuredClone(body) })
|
||||
const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), write = method !== 'GET'
|
||||
const key = write ? `${method}:${path}:${body?.idempotencyKey}` : null
|
||||
if (write) {
|
||||
assert.ok(body?.idempotencyKey, raw)
|
||||
if (ledger.has(key)) { const old = ledger.get(key); if (old.body !== JSON.stringify(body)) return response(null, 'A0409'); const data = structuredClone(old.data); if (data?.vehicles) data.replayed = true; return response(data) }
|
||||
}
|
||||
const remember = data => { ledger.set(key, { body: JSON.stringify(body), data: structuredClone(data) }); return response(data) }
|
||||
const draft = body?.draftToken ? drafts.get(body.draftToken) : null
|
||||
if (path === `${BASE}/drafts` && method === 'POST') { assert.deepEqual(Object.keys(body).sort(), ['idempotencyKey', 'warehouseId']); const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', containers: [], sources: [], expiresAt: '2099-01-01' }; drafts.set(d.draftToken, d); return remember(d) }
|
||||
if (path === `${BASE}/drafts` && method === 'GET') return response([...drafts.values()].filter(d => d.status === 'ACTIVE'))
|
||||
if (path === `${BASE}/candidates`) {
|
||||
assert.equal(params.get('warehouseId'), '44')
|
||||
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
|
||||
return response({ complete: true, snapshotAt: '2026-10-19', candidates: candidates.filter(c => !claimed.has(c.sourceKey)) })
|
||||
}
|
||||
if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(body.capacity, 15); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: 15, sourceKeys: [] }); draft.recordVersion++; return remember(draft) }
|
||||
if (path === `${BASE}/draft-locks/lock`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(body.source.sourceVersion.length, 64); const c = candidates.find(c => c.selection.planPalletId === body.source.planPalletId); assert.ok(c); draft.sources.push(structuredClone(c)); draft.containers.find(g => g.draftContainerId === body.draftContainerId).sourceKeys.push(c.sourceKey); draft.recordVersion++; return remember(draft) }
|
||||
if (path === `${BASE}/drafts/snapshot`) { assert.equal(body.recordVersion, draft.recordVersion); assert.ok(Array.isArray(body.containers)); assert.equal(Object.hasOwn(body, 'containersJson'), false); draft.containers = structuredClone(body.containers); draft.recordVersion++; return remember(draft) }
|
||||
if (path === `${BASE}/drafts/pre-arrange`) {
|
||||
assert.equal(body.recordVersion, draft.recordVersion)
|
||||
const matches = body.groups.every(g => JSON.stringify(g.dispatch.cargo.map(s => `PLAN:${s.planPalletId}`).sort()) === JSON.stringify(draft.containers.find(c => c.draftContainerId === g.draftContainerId).sourceKeys.toSorted()))
|
||||
if (!matches) { if (fault === 'reject-mutates-draft') draft.containers[0].sourceKeys = []; return response(null, 'A0409') }
|
||||
const vehicles = body.groups.map(g => {
|
||||
assert.equal(g.dispatch.vehicle, null); assert.equal(g.dispatch.driver, null)
|
||||
const selected = candidates.filter(c => g.dispatch.cargo.some(s => s.planPalletId === c.selection.planPalletId))
|
||||
const number = `DS-${orders.size + 1}`, items = selected.map(c => ({ dispatchItemId: String(++itemId), sourceKey: c.sourceKey, sourceVersion: c.sourceVersion, customerId: c.customerId, planPalletId: c.selection.planPalletId, warehousePalletId: c.selection.warehousePalletId, inboundCode: c.inboundCode, destination: c.destination, scheduledPackages: c.availablePackages, pickedPackages: 0, remove: { allowed: true } }))
|
||||
const o = { dispatchNo: number, recordVersion: 1, items }; orders.set(number, o)
|
||||
return { draftContainerId: g.draftContainerId, dispatch: { dispatchNo: number, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 1, replayed: false, counts: counts(o) } }
|
||||
})
|
||||
draft.status = 'CONVERTED'
|
||||
if (fault === 'unknown-convert') return { http: 0, body: null }
|
||||
if (fault === 'merged-groups') vehicles[1].dispatch.dispatchNo = vehicles[0].dispatch.dispatchNo
|
||||
return remember({ draftToken: draft.draftToken, vehicles, replayed: false })
|
||||
}
|
||||
const match = path.slice(BASE.length).match(/^\/([^/]+)(?:\/(items|route)(?:\/([^/]+)\/remove)?)?$/)
|
||||
if (path === BASE && method === 'GET') return response({ records: [...orders.values()].filter(o => o.dispatchNo === params.get('dispatchNo')).map(o => ({ dispatchNo: o.dispatchNo, warehouseId: '44', recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o) })) })
|
||||
const o = orders.get(match?.[1]); assert.ok(o, `${method} ${raw}`)
|
||||
if (method === 'POST' && match[3]) { assert.equal(body.recordVersion, o.recordVersion); o.items = o.items.filter(i => i.dispatchItemId !== match[3]); o.recordVersion++; return remember({ dispatchNo: o.dispatchNo, action: 'REMOVE_CARGO', recordVersion: o.recordVersion, replayed: false }) }
|
||||
if (match[2] === 'items') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: o.items })
|
||||
if (match[2] === 'route') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, stops: [{ index: 0, name: 'Origin', destinationKey: 'ORIGIN:44' }, ...o.items.map((i, n) => ({ index: n + 1, destinationKey: i.destination.key }))] })
|
||||
return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o), dispatchInfo: { route: o.items.map(i => i.destination.warehouseCode).join(',') },
|
||||
orderDetails: o.items.map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) })
|
||||
} }
|
||||
return { context, candidates, calls, checks, orders }
|
||||
}
|
||||
function counts(o) { return { itemCount: o.items.length, palletCount: o.items.length, packageCount: o.items.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: 0, shippedPackageCount: 0 } }
|
||||
|
||||
test('actual controller and OpenAPI publish versioned server drafts; standalone legacy creation is absent', () => {
|
||||
const controller = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java', import.meta.url), 'utf8')
|
||||
const yaml = readFileSync(new URL('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml', import.meta.url), 'utf8')
|
||||
for (const path of ['/drafts', '/drafts/containers', '/drafts/snapshot', '/draft-locks/lock', '/drafts/pre-arrange']) { assert.ok(controller.includes(`("${path}")`)); assert.ok(yaml.includes(` ${BASE}${path}:`)) }
|
||||
assert.equal(yaml.includes(` ${BASE}/pre-arrange:`), false)
|
||||
})
|
||||
test('two loading groups preserve distinct dispatches and exact physical sources', () => {
|
||||
assert.equal(groupedDispatchMatches(results(), groups, candidates), true)
|
||||
assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true)
|
||||
for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo },
|
||||
r => { r[1].draftContainerId = r[0].draftContainerId },
|
||||
r => { r[0].dispatch.items = r[1].dispatch.items },
|
||||
r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' },
|
||||
r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) {
|
||||
const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false)
|
||||
test('grouped native protocol proves exact groups, command replay, real readback and versioned source release', async () => {
|
||||
const h = server(), proof = await verifyGroupedDispatch(h.context, 'offline', h.candidates)
|
||||
assert.equal(proof.status, 'GROUPED_DISPATCH_VERIFIED'); assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED')
|
||||
assert.ok(h.checks.every(c => c.passed)); assert.equal(h.orders.size, 2); assert.ok([...h.orders.values()].every(o => !o.items.length))
|
||||
assert.equal(h.calls.filter(c => c.path.endsWith('/drafts/pre-arrange')).length, 3)
|
||||
assert.equal(h.calls.filter(c => c.path.match(/\/items\/\d+\/remove$/)).length, 2)
|
||||
assert.ok(h.calls.every(c => c.path !== `${BASE}/pre-arrange` && !/\/pick|\/depart|\/transfer-delivery/.test(c.path)))
|
||||
assert.ok(h.calls.filter(c => c.method !== 'GET' && c.path !== `${BASE}/drafts`).every(c => Object.hasOwn(c.body, 'recordVersion')))
|
||||
})
|
||||
test('actual grouping receipt rejects merged group identity even when totals match', () => {
|
||||
const c = rows(), groups = c.map((r, n) => ({ draftContainerId: String(n), sourceKeys: [r.sourceKey] }))
|
||||
const receipt = { vehicles: c.map((r, n) => ({ draftContainerId: String(n), dispatch: { dispatchNo: `DS-${n}`, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: { itemCount: 1, palletCount: 1, packageCount: r.availablePackages } } })) }
|
||||
assert.equal(groupedDispatchMatches(receipt, groups, c), true)
|
||||
receipt.vehicles[1].dispatch.dispatchNo = receipt.vehicles[0].dispatch.dispatchNo; assert.equal(groupedDispatchMatches(receipt, groups, c), false)
|
||||
})
|
||||
test('missing marker, source version, physical ownership or ambiguous FBA allocation is NOT_RUN before writes', async () => {
|
||||
for (const mutate of [h => { h.context.createdResources.phase4OverseasPda.marker = 'OLD' }, h => { delete h.candidates[0].sourceVersion }, h => { h.candidates[0].customerId = '999' }, h => { h.candidates[0].warehouseId = '999' }, h => { h.candidates[0].appointmentBatches = [{}, {}] }]) {
|
||||
const h = server(); mutate(h); assert.equal((await verifyGroupedDispatch(h.context, 'offline', h.candidates)).status, 'NOT_RUN'); assert.equal(h.calls.length, 0)
|
||||
}
|
||||
})
|
||||
function server(fault = '') {
|
||||
let draft, next = 0, arranged = false
|
||||
const calls = [], checks = [], orders = new Map()
|
||||
const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' })
|
||||
const context = { marker: 'CURRENT', createdResources: {},
|
||||
assert: (_m, label, _r, passed) => checks.push({ label, passed }),
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: structuredClone(body) })
|
||||
if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) }
|
||||
if (path.endsWith('/drafts/containers')) {
|
||||
assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity')
|
||||
const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity }
|
||||
draft.containers.push(container); return response(container)
|
||||
}
|
||||
if (path.endsWith('/draft-locks/lock')) {
|
||||
draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null)
|
||||
}
|
||||
if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : []))
|
||||
if (path.endsWith('/drafts/pre-arrange')) {
|
||||
if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) {
|
||||
if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = []
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
if (fault === 'unknown-post') return { http: 0, body: null }
|
||||
if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true }
|
||||
const result = groups.map(group => ({ draftContainerId: group.draftContainerId,
|
||||
dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) }))
|
||||
if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo
|
||||
return response(result)
|
||||
}
|
||||
if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) }
|
||||
if (path.includes('/candidates?')) return response(candidates)
|
||||
const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/)
|
||||
const order = orders.get(match?.[1]); assert.ok(order, path)
|
||||
if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) }
|
||||
return response(structuredClone(order))
|
||||
} }
|
||||
return { context, calls, checks }
|
||||
}
|
||||
test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => {
|
||||
const { context, calls, checks } = server()
|
||||
const proof = await verifyGroupedDispatch(context, 'offline', candidates)
|
||||
assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED')
|
||||
assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED')
|
||||
assert.equal(proof.results.length, 2)
|
||||
assert.ok(checks.every(row => row.passed))
|
||||
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3)
|
||||
assert.equal(calls.filter(row => row.method === 'DELETE').length, 2)
|
||||
assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path)))
|
||||
test('FBA allocations preserve actual BOOKING source and legal version zero; missing version is never guessed', () => {
|
||||
const c = rows()[0]; c.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:8', customerId: '55', recordVersion: 0, sources: [{ sourceKey: 'BOOKING:ACTUAL', destinationKey: 'FBA:A1', packageCount: 1 }] }]
|
||||
const body = createBody('CURRENT', 'FBA', [c]); assert.deepEqual(body.batchAllocations[0], { planPalletId: '21', batchKey: 'FBA_APPOINTMENT:8', customerId: '55', sourceKey: 'BOOKING:ACTUAL', destinationKey: 'FBA:A1', packageCount: 1, recordVersion: 0 })
|
||||
delete c.appointmentBatches[0].recordVersion; assert.throws(() => createBody('CURRENT', 'FBA', [c]), /权威recordVersion/)
|
||||
assert.equal(version(0), 0); for (const missing of [null, undefined, '', false, -1]) assert.throws(() => version(missing))
|
||||
assert.equal(version('9223372036854775807'), '9223372036854775807'); assert.equal(versionAfter('9007199254740993', '9007199254740992'), true)
|
||||
assert.equal(versionAfter('9223372036854775806', '9223372036854775807'), false)
|
||||
assert.throws(() => version('9223372036854775808')); assert.throws(() => version(9007199254740992))
|
||||
assert.throws(() => sourceSelection({ planPalletId: '21' }), /原生来源/)
|
||||
})
|
||||
test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => {
|
||||
const { context, calls } = server('reject-mutates-draft')
|
||||
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/)
|
||||
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1)
|
||||
assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED')
|
||||
test('rejection side effects and merged receipts stop without mutating or cleaning more resources', async () => {
|
||||
for (const fault of ['reject-mutates-draft', 'merged-groups']) {
|
||||
const h = server(fault); await assert.rejects(verifyGroupedDispatch(h.context, 'offline', h.candidates))
|
||||
assert.equal(h.calls.filter(c => /\/items\/\d+\/remove$/.test(c.path)).length, 0)
|
||||
assert.equal(h.context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW')
|
||||
}
|
||||
})
|
||||
test('unknown successful-write transport result is not automatically retried or deleted', async () => {
|
||||
const { context, calls } = server('unknown-post')
|
||||
await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/)
|
||||
assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2)
|
||||
assert.equal(calls.filter(row => row.method === 'DELETE').length, 0)
|
||||
assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0)
|
||||
assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW')
|
||||
test('unknown converted write keeps original request keys and forbids fresh writes across sessions', async () => {
|
||||
const h = server('unknown-convert'); await assert.rejects(verifyGroupedDispatch(h.context, 'offline', h.candidates))
|
||||
assert.equal(h.context.createdResources.groupedDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(h.orders.size, 2)
|
||||
assert.equal(h.calls.filter(c => c.path.endsWith('/drafts/pre-arrange')).length, 2); assert.equal(h.calls.filter(c => /\/remove$/.test(c.path)).length, 0)
|
||||
const io = dispatchIo(h.context, 'offline', {}, 'ANOTHER'); await assert.rejects(io.command('POST', BASE, {}, 'forbidden'), /停写/)
|
||||
})
|
||||
test('checkpoint failure before send causes no HTTP; callback records original intent before a successful send', async () => {
|
||||
const context = { marker: 'C', createdResources: {}, request: async () => { throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } }
|
||||
const proof = {}, io = dispatchIo(context, 'offline', proof, 'CHECKPOINT'); await assert.rejects(io.command('POST', BASE, { idempotencyKey: 'same' }, 'create'), /disk full/)
|
||||
assert.equal(proof.requests[0].status, 'NOT_SENT')
|
||||
let sent = false
|
||||
const noPersistence = { marker: 'NO_DISK', createdResources: {}, request: async () => { sent = true } }
|
||||
await assert.rejects(dispatchIo(noPersistence, 'offline', {}, 'NO_DISK').send('POST', BASE, { idempotencyKey: 'must-not-send' }, 'blocked'), /持久化checkpoint/); assert.equal(sent, false)
|
||||
const phases = [], other = { marker: 'D', createdResources: {}, checkpoint: async () => phases.push('checkpoint'), request: async () => { phases.push('request'); return { http: 200, body: { code: '200', data: { recorded: true } } } } }
|
||||
await dispatchIo(other, 'offline', {}, 'CHECKPOINT').command('POST', BASE, { idempotencyKey: 'same' }, 'create'); assert.deepEqual(phases, ['checkpoint', 'request', 'checkpoint'])
|
||||
})
|
||||
|
||||
test('5xx, transport failure and malformed success stop writes while preserving original diagnostic evidence', async () => {
|
||||
for (const response of [{ http: 503, raw: 'upstream failure', body: null }, { http: 0, raw: 'connection reset', transportError: { code: 'ECONNRESET' } }, { http: 200, raw: '<html>unreadable receipt</html>', body: null }, { http: 200, body: { code: '200', data: null } }]) {
|
||||
let sent = 0
|
||||
const context = { marker: 'UNKNOWN', createdResources: {}, checkpoint: async () => {}, request: async () => { sent++; return response } }, proof = {}, io = dispatchIo(context, 'offline', proof, 'UNKNOWN')
|
||||
await io.send('POST', BASE, { idempotencyKey: 'original-key' }, 'original create')
|
||||
assert.equal(proof.outcomeUnknown, true); assert.equal(proof.requests[0].status, 'UNKNOWN_RESULT'); assert.equal(proof.requests[0].body.idempotencyKey, 'original-key')
|
||||
assert.equal(proof.requests[0].raw, response.raw); assert.deepEqual(proof.requests[0].transportError, response.transportError)
|
||||
await assert.rejects(io.command('POST', BASE, { idempotencyKey: 'fresh-key' }, 'forbidden'), /停写/); assert.equal(sent, 1)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,124 +1,265 @@
|
||||
const BASE = '/api/platform/v1/last-leg/dispatches'
|
||||
const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const ok = response => response?.http === 200 && String(response.body?.code) === '200'
|
||||
const sorted = values => [...values].map(id).sort()
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, open, writeFile, rename } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
|
||||
export function groupedDispatchMatches(result, groups, candidates) {
|
||||
if (!Array.isArray(result) || result.length !== groups.length) return false
|
||||
const dispatchNos = new Set(), seen = new Set()
|
||||
return result.every(row => {
|
||||
const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId))
|
||||
const dispatch = row.dispatch
|
||||
if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo)
|
||||
|| dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false
|
||||
seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo)
|
||||
const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId)))
|
||||
if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds))
|
||||
|| dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length
|
||||
|| Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false
|
||||
return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId)
|
||||
&& id(candidate.warehousePalletId) === id(item.warehousePalletId)
|
||||
&& id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId)
|
||||
&& candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount)))
|
||||
export const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'
|
||||
export const id = value => value == null ? '' : String(value)
|
||||
export const canonical = value => JSON.stringify(value)
|
||||
export const success = response => response?.http === 200 && String(response.body?.code) === '200'
|
||||
const positiveId = value => /^[1-9]\d*$/.test(id(value))
|
||||
const amount = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0
|
||||
export class DispatchNotRun extends Error { constructor(message) { super(message); this.notRun = true } }
|
||||
export function version(value) {
|
||||
if (!(typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value))
|
||||
|| BigInt(value) > 9223372036854775807n) throw Error('缺少服务器recordVersion或精度无效,不能猜测零版本')
|
||||
return value
|
||||
}
|
||||
export const versionAfter = (next, previous) => BigInt(version(next)) > BigInt(version(previous))
|
||||
|
||||
// 主run提供真实原子落盘;独立调用者必须显式提供checkpoint,禁止内存意图冒充持久化。
|
||||
export async function dispatchEvidenceContext(context) {
|
||||
if (typeof context.checkpoint === 'function') return context
|
||||
if (typeof context.marker !== 'string' || !/^[A-Za-z0-9_-]+$/.test(context.marker)) throw new DispatchNotRun('缺少可用于独占检查点的本轮marker')
|
||||
const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase4-dispatch-checkpoints', `${context.marker}.json`)
|
||||
const redact = value => {
|
||||
if (Array.isArray(value)) return value.map(redact)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
|
||||
.filter(([name]) => !/password|authorization|secret|apiKey|credential|accessToken|refreshToken|idToken|^token$|base64|^bytes$|^content$/i.test(name))
|
||||
.map(([name, nested]) => [name, redact(nested)]))
|
||||
return value
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
let reservation
|
||||
try { reservation = await open(path, 'wx') }
|
||||
catch (error) { if (error.code === 'EEXIST') throw Error(`已存在本轮排车检查点,须核对原请求结果,禁止自动重跑:${path}`); throw error }
|
||||
await reservation.close()
|
||||
const scoped = Object.create(context)
|
||||
scoped.checkpoint = async () => {
|
||||
const resources = context.createdResources
|
||||
const evidence = redact({ marker: context.marker, boundary: resources.dispatchWriteBoundary,
|
||||
phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch })
|
||||
await writeFile(`${path}.tmp`, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8')
|
||||
await rename(`${path}.tmp`, path)
|
||||
}
|
||||
context.createdResources.phase4OverseasDispatch.checkpointPath = path
|
||||
await scoped.checkpoint()
|
||||
return scoped
|
||||
}
|
||||
export function query(path, values) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value))
|
||||
return `${path}?${params}`
|
||||
}
|
||||
export function commandKey(marker, namespace, action) {
|
||||
return `D4-${createHash('sha256').update(`${marker}\n${namespace}\n${action}`).digest('hex').slice(0, 48)}`
|
||||
}
|
||||
// 仅接受当前Candidate DTO;来源摘要、客户、仓与FBA批次均取生产回执。
|
||||
export function sourceSelection(candidate) {
|
||||
const source = candidate?.selection
|
||||
if (!positiveId(candidate?.customerId) || !positiveId(candidate?.warehouseId) || !amount(candidate?.availablePackages)
|
||||
|| !source || !/^[a-f\d]{64}$/i.test(source.sourceVersion || '') || source.sourceVersion !== candidate.sourceVersion
|
||||
|| !positiveId(source.warehousePalletId) || !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length
|
||||
|| !Array.isArray(candidate.appointmentBatches) || !candidate.destination?.key) throw new DispatchNotRun('缺少完整、可选的本轮原生来源回执')
|
||||
const expected = source.sourceType === 'INBOUND_PLAN' && positiveId(source.planPalletId) && source.returnReceiptId == null && source.relabelNo == null ? `PLAN:${source.planPalletId}`
|
||||
: source.sourceType === 'RETURN_RECEIPT' && positiveId(source.returnReceiptId) && source.planPalletId == null && source.relabelNo == null ? `RETURN:${source.returnReceiptId}`
|
||||
: source.sourceType === 'RELABEL' && source.relabelNo && source.planPalletId == null && source.returnReceiptId == null ? `RELABEL:${source.relabelNo}:${source.warehousePalletId}` : null
|
||||
if (!expected || candidate.sourceKey !== expected) throw new DispatchNotRun('来源选择器或服务器来源键不完整')
|
||||
return { sourceType: source.sourceType, planPalletId: source.planPalletId, returnReceiptId: source.returnReceiptId,
|
||||
relabelNo: source.relabelNo, warehousePalletId: source.warehousePalletId, sourceVersion: source.sourceVersion }
|
||||
}
|
||||
export function createBody(marker, namespace, candidates) {
|
||||
if (!candidates.length || new Set(candidates.map(c => id(c.warehouseId))).size !== 1) throw new DispatchNotRun('整车缺少同仓真实货物')
|
||||
const cargo = candidates.map(sourceSelection), batchAllocations = []
|
||||
for (const candidate of candidates) {
|
||||
const batches = candidate.appointmentBatches
|
||||
if (!batches.length) continue
|
||||
if (batches.length !== 1 || !positiveId(candidate.selection.planPalletId)) throw new DispatchNotRun('来源跨多个FBA批次,缺少本轮明确逐批实物分配,不能推算')
|
||||
const batch = batches[0]
|
||||
if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(candidate.customerId)
|
||||
|| !Array.isArray(batch.sources) || !batch.sources.length || batch.sources.some(s => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(s.sourceKey || '') || !s.destinationKey?.startsWith('FBA:') || !amount(s.packageCount))
|
||||
|| batch.sources.reduce((sum, s) => sum + Number(s.packageCount), 0) !== Number(candidate.availablePackages)) throw new DispatchNotRun('FBA批次未完整覆盖本托真实逐票货物')
|
||||
let batchVersion
|
||||
try { batchVersion = version(batch.recordVersion) } catch { throw new DispatchNotRun('FBA批次缺少权威recordVersion') }
|
||||
for (const source of batch.sources) batchAllocations.push({ planPalletId: candidate.selection.planPalletId, batchKey: batch.batchKey,
|
||||
customerId: candidate.customerId, sourceKey: source.sourceKey, destinationKey: source.destinationKey,
|
||||
packageCount: Number(source.packageCount), recordVersion: batchVersion })
|
||||
}
|
||||
return { idempotencyKey: commandKey(marker, namespace, 'create'), warehouseId: candidates[0].warehouseId,
|
||||
vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null,
|
||||
remark: `${marker}-${namespace}`, cargo, batchAllocations }
|
||||
}
|
||||
export function markerCandidates(context, candidates) {
|
||||
const fixture = context.createdResources?.phase4OverseasPda
|
||||
const pallets = new Set([...(fixture?.palletIds || []), ...Object.values(fixture?.generatedPalletsByInbound || {}).flat().filter(p => id(p.warehouseId) === id(fixture?.overseasWarehouseId)).map(p => p.palletId)].map(id))
|
||||
if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED' || !positiveId(fixture?.customerId) || !positiveId(fixture?.overseasWarehouseId)) throw new DispatchNotRun('缺少本轮PDA完成回执及真实客户仓')
|
||||
return candidates.filter(candidate => {
|
||||
try { sourceSelection(candidate) } catch { return false }
|
||||
return candidate.selection.sourceType === 'INBOUND_PLAN' && pallets.has(id(candidate.selection.warehousePalletId))
|
||||
&& id(candidate.customerId) === id(fixture.customerId) && id(candidate.warehouseId) === id(fixture.overseasWarehouseId)
|
||||
&& candidate.destination.type === 'FBA'
|
||||
})
|
||||
}
|
||||
|
||||
// Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence.
|
||||
export async function verifyGroupedDispatch(context, module, candidates) {
|
||||
if (!Array.isArray(candidates) || candidates.length < 2
|
||||
|| new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length
|
||||
|| new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length
|
||||
|| candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId']
|
||||
.some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key]))
|
||||
|| typeof row.inboundCode !== 'string' || !row.inboundCode.trim()
|
||||
|| !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) {
|
||||
throw Error('分组预排缺少本轮两个独立实物托盘')
|
||||
}
|
||||
const draftToken = `${context.marker}-GROUPED`.slice(0, 64)
|
||||
const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' }
|
||||
context.createdResources.groupedDispatch = proof
|
||||
const check = (label, response, passed) => {
|
||||
context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS)
|
||||
if (!passed) throw Error(`分组预排:${label}`)
|
||||
}
|
||||
const request = async (method, path, body) => {
|
||||
let response
|
||||
try { response = await context.request(method, path, body) }
|
||||
catch (error) {
|
||||
if (!['GET', 'HEAD'].includes(method)) {
|
||||
proof.outcomeUnknown = true
|
||||
proof.cleanup = 'PRESERVED_FOR_REVIEW'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) {
|
||||
proof.outcomeUnknown = true
|
||||
proof.cleanup = 'PRESERVED_FOR_REVIEW'
|
||||
}
|
||||
return response
|
||||
}
|
||||
const command = async (method, path, body, label) => {
|
||||
const response = await request(method, path, body)
|
||||
check(label, response, ok(response)); return response.body.data
|
||||
}
|
||||
const ownDraft = async () => {
|
||||
const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域')
|
||||
if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效')
|
||||
return rows.filter(row => row.draftToken === draftToken)
|
||||
}
|
||||
try {
|
||||
await command('POST', `${BASE}/drafts/snapshot`, { draftToken,
|
||||
planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿')
|
||||
for (const candidate of candidates) {
|
||||
const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组')
|
||||
if (!container?.id) throw Error('分组缺少服务端柜ID')
|
||||
const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] }
|
||||
proof.groups.push(group)
|
||||
await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId,
|
||||
planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组')
|
||||
}
|
||||
const before = JSON.stringify(await ownDraft())
|
||||
const swapped = proof.groups.map((group, index) => ({ ...group,
|
||||
planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds }))
|
||||
const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped })
|
||||
check('来源与分组不一致明确拒绝A0409', rejected,
|
||||
[200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409')
|
||||
check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before)
|
||||
const body = { draftToken, groups: proof.groups }
|
||||
const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车')
|
||||
if (Array.isArray(result)) proof.results = result
|
||||
check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates))
|
||||
const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放')
|
||||
check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result))
|
||||
proof.status = 'GROUPED_PREARRANGE_CHECKED'
|
||||
} finally {
|
||||
// Only release resources whose successful response identified an owned dispatch; never retry an unknown POST.
|
||||
if (!proof.outcomeUnknown) {
|
||||
for (const row of proof.results) {
|
||||
const dispatchNo = row.dispatch?.dispatchNo
|
||||
if (!dispatchNo) continue
|
||||
const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货')
|
||||
check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE'
|
||||
&& groupedDispatchMatches([{ ...row, dispatch: detail }],
|
||||
proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates)
|
||||
&& detail.items.every(item => /^[1-9]\d*$/.test(id(item.id)))
|
||||
&& Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0)
|
||||
for (const item of detail.items) {
|
||||
await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`,
|
||||
undefined, '移除分组测试明细释放正式托盘锁')
|
||||
}
|
||||
const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零')
|
||||
check('分组对照排车释放后数量归零', null,
|
||||
empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0)
|
||||
}
|
||||
await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿')
|
||||
const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排')
|
||||
check('分组验收来源回到正式候选', null, Array.isArray(available)
|
||||
&& candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId))))
|
||||
proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED'
|
||||
}
|
||||
}
|
||||
proof.status = 'GROUPED_PREARRANGE_VERIFIED'
|
||||
export function notRun(context, proof, reason) {
|
||||
proof.status = 'NOT_RUN'; proof.reason = reason
|
||||
context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true })
|
||||
return proof
|
||||
}
|
||||
// 请求意图必须先持久化,未知写结果使同marker后续写入全部停止。
|
||||
export function dispatchIo(context, module, proof, namespace) {
|
||||
context.createdResources ??= {}; proof.requests ??= []
|
||||
const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false }
|
||||
if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker')
|
||||
const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() }
|
||||
const check = (label, response, passed) => {
|
||||
context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true })
|
||||
if (!passed) throw Error(`${namespace}: ${label}`)
|
||||
}
|
||||
const send = async (method, path, body, label) => {
|
||||
const write = !['GET', 'HEAD'].includes(method)
|
||||
if (write && boundary.stopped) throw Error('本轮写请求结果未知或回执存档失败,停写并保留原键')
|
||||
if (write && typeof context.checkpoint !== 'function') throw new DispatchNotRun('缺少真实持久化checkpoint,未发送排车写请求')
|
||||
const entry = { method, path, ...(body === undefined ? {} : { body: structuredClone(body) }), label, status: 'SENDING' }
|
||||
proof.requests.push(entry)
|
||||
try { await checkpoint() } catch (error) { boundary.stopped = true; entry.status = 'NOT_SENT'; throw error }
|
||||
let response
|
||||
try { response = await context.request(method, path, body) }
|
||||
catch (error) { entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; if (write) { boundary.stopped = true; boundary.outcomeUnknown = true; proof.outcomeUnknown = true; proof.cleanup = 'PRESERVED_FOR_REVIEW' }; await checkpoint(); throw error }
|
||||
const unknown = !response?.http || response.http >= 500 || write && (response.body?.code == null || success(response) && response.body.data == null)
|
||||
Object.assign(entry, { status: unknown ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http,
|
||||
code: response?.body?.code, message: response?.body?.message, raw: response?.raw, transportError: response?.transportError,
|
||||
requestId: response?.requestId, traceId: response?.traceId, result: structuredClone(response?.body?.data) })
|
||||
if (write && entry.status === 'UNKNOWN_RESULT') { boundary.stopped = true; boundary.outcomeUnknown = true; proof.outcomeUnknown = true; proof.cleanup = 'PRESERVED_FOR_REVIEW' }
|
||||
try { await checkpoint() } catch (error) { if (write) boundary.stopped = true; throw error }
|
||||
return response
|
||||
}
|
||||
const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data }
|
||||
const key = action => commandKey(context.marker, namespace, action)
|
||||
return { check, send, command, key }
|
||||
}
|
||||
export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy }
|
||||
export async function replay(io, path, body, result, label, method = 'POST') {
|
||||
const again = await io.command(method, path, body, label)
|
||||
io.check(`${label}恢复原命令结果`, null, canonical(replayFacts(again)) === canonical(replayFacts(result)))
|
||||
return again
|
||||
}
|
||||
export async function candidatePage(io, warehouseId, extra = {}) {
|
||||
const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选')
|
||||
io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates))
|
||||
return result.candidates
|
||||
}
|
||||
export async function readDispatch(io, dispatchNo) {
|
||||
const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
const data = await io.command('GET', path, undefined, '回查原生整车详情')
|
||||
const items = await io.command('GET', `${path}/items`, undefined, '回查真实货物明细')
|
||||
const route = await io.command('GET', `${path}/route`, undefined, '回查真实起点与目的地')
|
||||
const page = await io.command('GET', query(DISPATCH_BASE, { page: 1, size: 20, dispatchNo }), undefined, '按业务号核对整车仓库')
|
||||
const rows = page?.records?.filter(r => r.dispatchNo === dispatchNo) || []
|
||||
io.check('主单、货物、路线版本与实际仓一致', null, data?.dispatchNo === dispatchNo && items?.dispatchNo === dispatchNo && route?.dispatchNo === dispatchNo
|
||||
&& rows.length === 1 && positiveId(rows[0].warehouseId) && id(version(data.recordVersion)) === id(version(items.recordVersion))
|
||||
&& id(version(route.recordVersion)) === id(version(data.recordVersion)) && canonical(rows[0].counts) === canonical(data.counts)
|
||||
&& id(rows[0].recordVersion) === id(data.recordVersion) && rows[0].status === data.status && rows[0].stage === data.stage
|
||||
&& items.status === data.status && items.stage === data.stage)
|
||||
return { data, items, route, warehouseId: rows[0].warehouseId }
|
||||
}
|
||||
export function dispatchMatches(bundle, candidates) {
|
||||
const items = bundle?.items?.items, counts = bundle?.data?.counts
|
||||
if (!Array.isArray(items) || !counts || !Array.isArray(candidates) || bundle.data.status !== 'ACTIVE' || bundle.data.stage !== 'BEFORE_LOADING'
|
||||
|| items.length !== candidates.length || Number(counts.itemCount) !== items.length || Number(counts.palletCount) !== new Set(candidates.map(c => id(c.selection.warehousePalletId))).size
|
||||
|| Number(counts.packageCount) !== candidates.reduce((n, c) => n + Number(c.availablePackages), 0) || Number(counts.pickedPackageCount) !== 0 || Number(counts.shippedPackageCount) !== 0) return false
|
||||
const details = bundle.data.orderDetails
|
||||
if (!Array.isArray(details) || new Set(details.map(d => d.sourceKey)).size !== candidates.length
|
||||
|| details.some(d => !candidates.some(c => c.sourceKey === d.sourceKey && id(c.customerId) === id(d.customerId)
|
||||
&& c.inboundCode === d.inboundCode && c.destination.warehouseCode === d.destinationFbaWarehouse))) return false
|
||||
return new Set(items.map(i => id(i.dispatchItemId))).size === items.length && new Set(items.map(i => i.sourceKey)).size === items.length && items.every(i => positiveId(i.dispatchItemId) && candidates.some(c =>
|
||||
c.sourceKey === i.sourceKey && c.sourceVersion === i.sourceVersion && id(c.customerId) === id(i.customerId) && id(c.warehouseId) === id(bundle.warehouseId)
|
||||
&& id(c.selection.warehousePalletId) === id(i.warehousePalletId) && id(c.selection.planPalletId) === id(i.planPalletId)
|
||||
&& c.inboundCode === i.inboundCode && c.destination.key === i.destination?.key && Number(c.availablePackages) === Number(i.scheduledPackages) && Number(i.pickedPackages) === 0))
|
||||
}
|
||||
export function groupedDispatchMatches(result, groups, candidates) {
|
||||
if (!Array.isArray(result?.vehicles) || result.vehicles.length !== groups.length) return false
|
||||
const seen = new Set(), numbers = new Set()
|
||||
return result.vehicles.every(row => {
|
||||
const group = groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)), command = row.dispatch
|
||||
const selected = candidates.filter(c => group?.sourceKeys.includes(c.sourceKey))
|
||||
if (!group || seen.has(id(row.draftContainerId)) || numbers.has(command?.dispatchNo) || !command?.dispatchNo || command.action !== 'CREATE' || command.status !== 'ACTIVE' || command.stage !== 'BEFORE_LOADING') return false
|
||||
seen.add(id(row.draftContainerId)); numbers.add(command.dispatchNo)
|
||||
return Number(command.counts?.itemCount) === selected.length && Number(command.counts?.palletCount) === selected.length
|
||||
&& Number(command.counts?.packageCount) === selected.reduce((n, c) => n + Number(c.availablePackages), 0)
|
||||
})
|
||||
}
|
||||
export async function newDraft(io, warehouseId) {
|
||||
const body = { idempotencyKey: io.key('draft-create'), warehouseId }
|
||||
const draft = await io.command('POST', `${DISPATCH_BASE}/drafts`, body, '创建服务器草稿token')
|
||||
io.check('服务器新草稿身份及零版本', null, Boolean(draft?.draftToken) && id(draft.warehouseId) === id(warehouseId) && id(version(draft.recordVersion)) === '0' && Array.isArray(draft.containers) && Array.isArray(draft.sources))
|
||||
await replay(io, `${DISPATCH_BASE}/drafts`, body, draft, '创建草稿同键重放')
|
||||
return draft
|
||||
}
|
||||
export async function activeDraft(io, token) {
|
||||
const rows = await io.command('GET', `${DISPATCH_BASE}/drafts`, undefined, '回查当前拥有者草稿')
|
||||
io.check('原服务器草稿唯一存在', null, Array.isArray(rows) && rows.filter(d => d.draftToken === token).length === 1)
|
||||
return rows.find(d => d.draftToken === token)
|
||||
}
|
||||
// 分组全成功才移除已证明属于本轮的未拣实物;失败或未知响应保留所有已成功资源。
|
||||
export async function verifyGroupedDispatch(context, module, candidates) {
|
||||
const proof = { marker: context.marker, status: 'CHECKING', groups: [], results: [], cleanup: 'PRESERVED_FOR_REVIEW', requests: [] }
|
||||
if (context.createdResources?.groupedDispatch?.requests?.length) throw Error('本轮分组尝试已经存在,禁止自动重造')
|
||||
context.createdResources ??= {}; context.createdResources.groupedDispatch = proof
|
||||
try {
|
||||
const owned = markerCandidates(context, candidates)
|
||||
if (owned.length !== candidates.length || candidates.length < 2 || new Set(candidates.map(c => c.sourceKey)).size !== candidates.length
|
||||
|| new Set(candidates.map(c => id(c.selection?.warehousePalletId))).size !== candidates.length) throw new DispatchNotRun('分组须有本轮两个独立未拣实托')
|
||||
candidates.forEach((c, n) => createBody(context.marker, `GROUP-${n}`, [c]))
|
||||
const io = dispatchIo(context, module, proof, 'GROUPED')
|
||||
let draft = await newDraft(io, candidates[0].warehouseId); proof.draftToken = draft.draftToken
|
||||
for (const [n, candidate] of candidates.entries()) {
|
||||
const previous = new Set(draft.containers.map(c => id(c.draftContainerId)))
|
||||
draft = await io.command('POST', `${DISPATCH_BASE}/drafts/containers`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(`container-${n}`), draftToken: draft.draftToken, capacity: 15 }, '创建真实草稿容器')
|
||||
const additions = draft.containers.filter(c => !previous.has(id(c.draftContainerId)))
|
||||
io.check('容器身份来自服务器草稿', null, additions.length === 1 && positiveId(additions[0].draftContainerId))
|
||||
const group = { draftContainerId: additions[0].draftContainerId, sourceKeys: [candidate.sourceKey] }; proof.groups.push(group)
|
||||
const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(`lock-${n}`), draftToken: draft.draftToken, draftContainerId: group.draftContainerId, source: sourceSelection(candidate) }
|
||||
draft = await io.command('POST', `${DISPATCH_BASE}/draft-locks/lock`, body, '锁定完整真实来源到原容器')
|
||||
await replay(io, `${DISPATCH_BASE}/draft-locks/lock`, body, draft, '锁定同键重放')
|
||||
}
|
||||
const snapshot = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('snapshot'), draftToken: draft.draftToken, planIds: [], containers: draft.containers }
|
||||
draft = await io.command('POST', `${DISPATCH_BASE}/drafts/snapshot`, snapshot, '保存版本化完整容器布局')
|
||||
await replay(io, `${DISPATCH_BASE}/drafts/snapshot`, snapshot, draft, '布局同键重放')
|
||||
const groups = proof.groups.map((g, n) => ({ draftContainerId: g.draftContainerId, dispatch: createBody(context.marker, `GROUPED-CHILD-${n}`, candidates.filter(c => g.sourceKeys.includes(c.sourceKey))) }))
|
||||
const before = await activeDraft(io, draft.draftToken)
|
||||
const wrong = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert-reject'), draftToken: draft.draftToken,
|
||||
groups: groups.map((g, n) => ({ ...g, dispatch: groups[(n + 1) % groups.length].dispatch })) }
|
||||
const rejected = await io.send('POST', `${DISPATCH_BASE}/drafts/pre-arrange`, wrong, '错组转换拒绝')
|
||||
io.check('错组来源明确冲突', rejected, [200, 409].includes(rejected?.http) && String(rejected.body?.code) === 'A0409')
|
||||
io.check('拒绝后原草稿不变', null, canonical(await activeDraft(io, draft.draftToken)) === canonical(before))
|
||||
const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups }
|
||||
const converted = await io.command('POST', `${DISPATCH_BASE}/drafts/pre-arrange`, body, '原子转换一容器一整车')
|
||||
io.check('真实分组命令数量与货物核平', null, converted?.draftToken === draft.draftToken && groupedDispatchMatches(converted, proof.groups, candidates))
|
||||
proof.results = converted.vehicles
|
||||
for (const row of proof.results) {
|
||||
const selected = candidates.filter(c => proof.groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)).sourceKeys.includes(c.sourceKey))
|
||||
const current = await readDispatch(io, row.dispatch.dispatchNo)
|
||||
io.check('成功创建后逐车回查真实来源', null, dispatchMatches(current, selected))
|
||||
}
|
||||
await replay(io, `${DISPATCH_BASE}/drafts/pre-arrange`, body, converted, '整批转换同键重放')
|
||||
for (const row of proof.results) {
|
||||
let current = await readDispatch(io, row.dispatch.dispatchNo)
|
||||
const selected = candidates.filter(c => proof.groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)).sourceKeys.includes(c.sourceKey))
|
||||
io.check('清理前仍为本轮完整未拣货物', null, dispatchMatches(current, selected))
|
||||
for (const item of current.items.items) {
|
||||
io.check('服务端允许移出本轮实物', null, item.remove?.allowed === true)
|
||||
const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key(`remove-${item.dispatchItemId}`), reason: `${context.marker}-分组验收释放未拣实物` }
|
||||
await io.command('POST', `${DISPATCH_BASE}/${encodeURIComponent(row.dispatch.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '版本化移出本轮分组实物')
|
||||
current = await readDispatch(io, row.dispatch.dispatchNo)
|
||||
}
|
||||
io.check('空对照单真实数量归零', null, dispatchMatches(current, []))
|
||||
}
|
||||
const available = await candidatePage(io, candidates[0].warehouseId)
|
||||
io.check('原来源回到可选集合', null, candidates.every(c => available.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
|
||||
proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED'; proof.status = 'GROUPED_DISPATCH_VERIFIED'; await context.checkpoint()
|
||||
return proof
|
||||
} catch (error) {
|
||||
if (error.notRun && !proof.requests.length) return notRun(context, proof, error.message)
|
||||
proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs'
|
||||
|
||||
@@ -17,7 +18,7 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文
|
||||
})
|
||||
|
||||
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 214]]) {
|
||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 228]]) {
|
||||
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, '')
|
||||
@@ -76,7 +77,47 @@ test('退回实收按真实流水唯一保存,重派拣货必须明确唯一
|
||||
}
|
||||
const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order')
|
||||
assert.match(pick, /\breceipt_id BIGINT NULL/i)
|
||||
assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) = 1\)/i)
|
||||
assert.match(pick, /\bdispatch_item_source_id BIGINT NULL/i)
|
||||
assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) \+ \(dispatch_item_source_id IS NOT NULL\) = 1\)/i)
|
||||
})
|
||||
|
||||
test('同实托初始化分离无代表客户的物理占用与逐来源占用', () => {
|
||||
const physical = table(overseas, 'baiyue_lastleg', 'll_dispatch_physical_pallet_lock')
|
||||
assert.doesNotMatch(physical, /\bcustomer_id\b/i)
|
||||
assert.match(physical, /UNIQUE KEY[^\n]*\(active_warehouse_pallet_id\)/i)
|
||||
assert.match(physical, /CHECK\(\(status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id\)/i)
|
||||
assert.match(physical, /dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL/i)
|
||||
assert.match(physical, /dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL/i)
|
||||
const logical = table(overseas, 'baiyue_lastleg', 'll_dispatch_pallet_lock')
|
||||
assert.match(logical, /physical_lock_id BIGINT NULL/i)
|
||||
assert.match(logical, /UNIQUE KEY[^\n]*\(customer_id,active_key\)/i)
|
||||
assert.doesNotMatch(logical, /active_warehouse_pallet_id/i)
|
||||
assert.match(table(overseas, 'baiyue_lastleg', 'll_dispatch_item'), /UNIQUE KEY[^\n]*\(dispatch_order_id,source_key\)/i)
|
||||
const source = table(overseas, 'baiyue_lastleg', 'll_dispatch_item_source')
|
||||
assert.match(source, /UNIQUE KEY[^\n]*\(dispatch_item_id,relabel_item_id,inventory_position_id\)/i)
|
||||
assert.match(source, /product_quantity BIGINT NULL/i)
|
||||
assert.match(source, /source_inbound_order_id BIGINT NULL/i)
|
||||
assert.match(source, /relabel_round_key VARCHAR\(128\) NOT NULL/i)
|
||||
assert.match(source, /unit_facts_json JSON NOT NULL/i)
|
||||
})
|
||||
|
||||
test('实托组成串行点只保存真实主键和首次创建时间', () => {
|
||||
const guard = table(overseas, 'baiyue_lastleg', 'll_physical_pallet_guard')
|
||||
assert.match(guard, /warehouse_pallet_id BIGINT NOT NULL/i)
|
||||
assert.match(guard, /created_at DATETIME\(3\) NOT NULL DEFAULT CURRENT_TIMESTAMP\(3\)/i)
|
||||
assert.match(guard, /PRIMARY KEY\s*\(warehouse_pallet_id\)/i)
|
||||
assert.doesNotMatch(guard, /\b(?:customer_id|status|expires_at|dispatch_order_id|draft_token|draft_container_id)\b/i)
|
||||
})
|
||||
|
||||
test('同实托六表定义在模块和统一空库入口保持一致', () => {
|
||||
const module = 'USE baiyue_lastleg;\n' + readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8')
|
||||
.replace(/^[ \t]*CREATE TABLE IF NOT EXISTS /gm, 'CREATE TABLE ')
|
||||
.replace(/^[ \t]+\) ENGINE=/gm, ') ENGINE=')
|
||||
const body = value => value.slice(value.indexOf('('), value.lastIndexOf(') ENGINE='))
|
||||
.replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').trim()
|
||||
for (const name of ['ll_dispatch_item', 'll_dispatch_item_source', 'll_physical_pallet_guard', 'll_dispatch_physical_pallet_lock', 'll_dispatch_pallet_lock', 'll_dispatch_pda_pick_order']) {
|
||||
assert.equal(body(table(overseas, 'baiyue_lastleg', name)), body(table(module, 'baiyue_lastleg', name)), name)
|
||||
}
|
||||
})
|
||||
|
||||
test('车辆报价与实测记录保留业务去重和不可变版本', () => {
|
||||
@@ -133,7 +174,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: 434, iam_role_permission: 618,
|
||||
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)
|
||||
@@ -160,8 +201,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(!permissions.some(row => row.permission_code === 'merchant:warehouse:delivery:create'))
|
||||
assert.ok(!merchant.has('merchant:warehouse:delivery:create'))
|
||||
const admin = roleAuthorities(cn, 'PLATFORM_ADMIN')
|
||||
for (const action of ['create', 'loading-complete', 'depart']) {
|
||||
const code = `platform:overseas-warehouse:dispatch:${action}`
|
||||
assert.ok(admin.has(code), code)
|
||||
assert.ok(!merchant.has(code), code)
|
||||
}
|
||||
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}`))
|
||||
|
||||
@@ -39,16 +39,24 @@ test('transfer review integration covers both source types and known blank-reaso
|
||||
|
||||
test('draft and removal checks use actual IDs plus readback rather than snapshot-only success', async () => {
|
||||
const source = await read('phase4-overseas-dispatch.mjs')
|
||||
for (const value of ['drafts/containers', 'draftContainerId: containerId', 'container.palletIds',
|
||||
'draft-locks/unlock', 'deletedDrafts', 'removableItem.id', 'removedDetail.data?.packageCount', 'freed.data.some'])
|
||||
for (const value of ['drafts/containers', 'draftContainerId: temporary', 'draft.containers',
|
||||
'draft-locks/unlock', 'drafts/pre-arrange', 'item.dispatchItemId', 'current.data.counts.palletCount',
|
||||
'available.some(c => c.sourceKey === duplicate.sourceKey', 'dispatchBusinessSnapshot(await readDispatch'])
|
||||
assert.ok(source.includes(value), value)
|
||||
})
|
||||
|
||||
test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => {
|
||||
const source = await read('phase5-overseas-fulfillment.mjs')
|
||||
for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery',
|
||||
'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map'])
|
||||
'replay.data?.replayed', 'JSON.stringify(replay.data.result) === JSON.stringify(result)',
|
||||
'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)'])
|
||||
assert.ok(source.includes(value), value)
|
||||
const helper = await read('fleet-trip-fulfillment.mjs')
|
||||
for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)',
|
||||
'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command',
|
||||
"{ code: 'A0403', message: '该请求标识不属于当前操作员' }", 'JSON.stringify(replay.result) === JSON.stringify(confirmed.result)',
|
||||
'JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay)'])
|
||||
assert.ok(helper.includes(value), value)
|
||||
})
|
||||
|
||||
test('return merge sources are actual consumed batches, not a synthetic MERGED identifier', async () => {
|
||||
|
||||
@@ -1,193 +1,180 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dispatchBusinessSnapshot, dispatchRouteSnapshot, dispatchRouteMatches,
|
||||
selectDispatchRouteFixture, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, basename } from 'node:path'
|
||||
import { DISPATCH_BASE as BASE, dispatchEvidenceContext } from './grouped-dispatch-acceptance.mjs'
|
||||
import { run, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs'
|
||||
|
||||
const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url)
|
||||
const source = await readFile(fileURLToPath(moduleUrl), 'utf8')
|
||||
|
||||
test('phase4 overseas dispatch module freezes only stable platform query and smart pre-arrange paths', () => {
|
||||
for (const token of [
|
||||
"const BASE = '/api/platform/v1/last-leg/dispatches'",
|
||||
"query(BASE, { page: 1, size: 20 })",
|
||||
'`${BASE}/candidates`', '`${BASE}/drafts`', '`${BASE}/drafts/snapshot`',
|
||||
'`${BASE}/draft-locks/lock`', '`${BASE}/pre-arrange`', '`${BASE}/draft-locks/release`',
|
||||
'`${BASE}/${encodeURIComponent(dispatchNo)}`',
|
||||
]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/depart', '/cancel']) {
|
||||
assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('module uses marker-owned completed PDA pallets and never borrows historical candidates', () => {
|
||||
assert.match(source, /fixture\?\.marker !== context\.marker/)
|
||||
assert.match(source, /fixture\?\.finalStatus !== 'COMPLETED'/)
|
||||
assert.match(source, /fixture\.palletIds\.has\(id\(value\?\.warehousePalletId\)\)/)
|
||||
assert.match(source, /id\(value\?\.overseasWarehouseId\) === id\(fixture\.overseasWarehouseId\)/)
|
||||
assert.doesNotMatch(source, /candidates\[0\]/)
|
||||
})
|
||||
|
||||
test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => {
|
||||
assert.match(source, /const candidatesByDestination = new Map\(selectedCandidates.map/)
|
||||
assert.match(source, /row\?\.fbaWarehouseCode \|\| row\?\.destinationWarehouseCode/)
|
||||
assert.match(source, /selectedCandidates\.length >= 2/)
|
||||
assert.match(source, /planPalletIds, fbaAppointmentNo: null/)
|
||||
assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/)
|
||||
})
|
||||
|
||||
function routeCandidates() {
|
||||
return [['20', '120', 'ABE4', 2], ['30', '130', 'ABE8', 3], ['21', '121', 'ABE4', 4]].map(([planPalletId, warehousePalletId, fbaWarehouseCode, packageCount]) => ({
|
||||
planId: '10', planPalletId, warehousePalletId, fbaWarehouseCode, destinationWarehouseCode: fbaWarehouseCode,
|
||||
overseasWarehouseId: '900', customerId: '100', inboundCode: `IN-${planPalletId}`, sourceStage: 'RECEIVED', destinationType: 'FBA', packageCount,
|
||||
}))
|
||||
}
|
||||
function responseFor(candidates) {
|
||||
return { id: '1', dispatchNo: 'DS-TEST', overseasWarehouseId: '900', status: 'PENDING_DEPARTURE',
|
||||
route: [...new Set(candidates.map(row => row.fbaWarehouseCode))].join(','),
|
||||
items: candidates.map(row => ({ ...row, id: '8' + row.planPalletId })),
|
||||
itemCount: candidates.length, palletCount: candidates.length,
|
||||
packageCount: candidates.reduce((sum, row) => sum + row.packageCount, 0), pickedPackageCount: 0, shippedPackageCount: 0 }
|
||||
}
|
||||
|
||||
test('C52 fixture needs two destinations and two distinct physical pallets at one destination', () => {
|
||||
const rows = routeCandidates()
|
||||
assert.equal(selectDispatchRouteFixture(rows.slice(0, 2)), null)
|
||||
assert.equal(selectDispatchRouteFixture([rows[0], rows[2]]), null)
|
||||
assert.equal(selectDispatchRouteFixture([rows[0], rows[1], { ...rows[2], warehousePalletId: rows[0].warehousePalletId }]), null)
|
||||
const result = selectDispatchRouteFixture(rows)
|
||||
assert.deepEqual(result.selected.map(row => row.planPalletId), ['20', '30'])
|
||||
assert.equal(result.duplicate.planPalletId, '21')
|
||||
assert.match(source, /generatedPalletsByInbound/)
|
||||
assert.match(source, /id\(value\?\.customerId\) === id\(fixture.customerId\)/)
|
||||
assert.match(source, /value\?\.destinationType === 'FBA'/)
|
||||
})
|
||||
|
||||
test('C52 independently derives route from actual rows and rejects fabricated destinations, sources, totals and duplicate route codes', () => {
|
||||
const rows = routeCandidates(), value = responseFor(rows)
|
||||
assert.equal(dispatchRouteMatches(value, rows), true)
|
||||
for (const route of ['ABE4', 'ABE4,ABE8,ABE9', 'ABE4,ABE8,ABE4', 'ABE4,ABE8,', '']) {
|
||||
assert.equal(dispatchRouteMatches({ ...value, route }, rows), false)
|
||||
}
|
||||
for (const patch of [{ itemCount: 9 }, { packageCount: 10 }, { overseasWarehouseId: '901' }, { status: 'PICKING' }]) {
|
||||
assert.equal(dispatchRouteMatches({ ...value, ...patch }, rows), false)
|
||||
}
|
||||
for (const patch of [{ inboundCode: 'FOREIGN' }, { warehousePalletId: '999' }, { fbaWarehouseCode: 'ABE9' }, { planPalletId: '999' }]) {
|
||||
assert.equal(dispatchRouteMatches({ ...value, items: [{ ...value.items[0], ...patch }, ...value.items.slice(1)] }, rows), false)
|
||||
}
|
||||
assert.notDeepEqual(dispatchRouteSnapshot(value), dispatchRouteSnapshot({ ...value, items: [{ ...value.items[0], id: '999' }, ...value.items.slice(1)] }))
|
||||
assert.notEqual(dispatchBusinessSnapshot(value), dispatchBusinessSnapshot({ ...value, route: 'ABE9' }))
|
||||
})
|
||||
|
||||
function lifecycleHarness(fault = '') {
|
||||
const rows = routeCandidates(), selected = rows.slice(0, 2)
|
||||
let state = responseFor(selected), nextItemId = 900, preArrangeCount = 0
|
||||
const calls = [], checks = []
|
||||
const base = '/api/platform/v1/last-leg/dispatches'
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'REQ', traceId: 'TRACE' })
|
||||
const error = code => ({ http: 200, body: { code }, requestId: 'REQ', traceId: 'TRACE' })
|
||||
const context = { marker: 'CURRENT', createdResources: { phase4OverseasDispatch: {} },
|
||||
apiSuccess: (_module, _name, response) => response.body.data,
|
||||
assert: (_module, name, _response, passed) => checks.push({ name, passed }),
|
||||
request: async (method, path, body) => {
|
||||
calls.push({ method, path, body })
|
||||
if (path.includes('/candidates')) return ok(rows.filter(row => !state.items.some(item => item.planPalletId === row.planPalletId)))
|
||||
if (path.endsWith('/draft-locks/lock')) return ok(null)
|
||||
if (method === 'GET') return ok(fault === 'wrong-route' ? { ...state, route: 'ABE9' } : state)
|
||||
if (method === 'DELETE') {
|
||||
const itemId = path.split('/').at(-1), remaining = state.items.filter(row => row.id !== itemId)
|
||||
if (remaining.length === state.items.length) {
|
||||
if (fault === 'rejected-delete-mutates') state.route = 'ABE9'
|
||||
return error('A0404')
|
||||
const clone = value => structuredClone(value)
|
||||
const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) },
|
||||
sourceKey: `PLAN:2${n}`, sourceVersion: `${n}`.repeat(64), customerId: '55', warehouseId: '44', inboundCode: `IN-${n}`, palletNo: `P-${n}`,
|
||||
destination: { key: `FBA:A${n === 3 ? 1 : n}`, type: 'FBA', warehouseCode: `A${n === 3 ? 1 : n}` }, availablePackages: n + 1, appointmentBatches: [], blockReasons: [] }))
|
||||
// All state here is offline test data. This server rejects missing versions and preserves original-key results.
|
||||
function server(fault = '', initial = false) {
|
||||
const candidates = rows(), drafts = new Map(), orders = new Map(), ledger = new Map(), calls = [], checks = []
|
||||
let serial = 0, containerId = 60, itemId = 70
|
||||
const counts = o => ({ itemCount: o.items.length, palletCount: o.items.length, packageCount: o.items.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: 0, shippedPackageCount: 0 })
|
||||
const item = c => ({ dispatchItemId: String(++itemId), sourceType: c.selection.sourceType, sourceKey: c.sourceKey, sourceVersion: c.sourceVersion, customerId: c.customerId,
|
||||
planPalletId: c.selection.planPalletId, warehousePalletId: c.selection.warehousePalletId, inboundCode: c.inboundCode, destination: clone(c.destination), scheduledPackages: c.availablePackages, pickedPackages: 0, remove: { allowed: true } })
|
||||
const syncRoute = o => { o.keys = [...new Set([...(fault === 'append-only-route' ? o.keys || [] : []), ...o.items.map(i => i.destination.key)])] }
|
||||
const create = cargo => { const o = { dispatchNo: `DS-${orders.size + 1}`, recordVersion: initial && orders.size === 0 ? 0 : 1, items: cargo.map(s => item(candidates.find(c => c.selection.planPalletId === s.planPalletId))) }; syncRoute(o); orders.set(o.dispatchNo, o); return o }
|
||||
const command = (o, action) => ({ dispatchNo: o.dispatchNo, action, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o), replayed: false })
|
||||
const view = o => ({ dispatchNo: o.dispatchNo, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: o.recordVersion, counts: counts(o),
|
||||
dispatchInfo: { route: [...new Set(o.items.map(i => i.destination.warehouseCode))].join(',') },
|
||||
orderDetails: o.items.map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) })
|
||||
const stops = o => [{ index: 0, name: 'Actual Origin', address: 'Warehouse 44', destinationKey: 'ORIGIN:44' }, ...o.keys.map((key, n) => ({ index: n + 1, name: key, destinationKey: key }))]
|
||||
const response = (data, code = '200') => ({ http: 200, body: { code, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' })
|
||||
const context = { marker: 'CURRENT', createdResources: { phase4OverseasPda: { marker: 'CURRENT', finalStatus: 'COMPLETED', customerId: '55', overseasWarehouseId: '44', palletIds: ['31', '32', '33'] } },
|
||||
checkpoint: async () => {}, // Explicit offline double; full-run test below removes it and verifies actual disk writes.
|
||||
assert: (_module, label, _response, passed) => checks.push({ label, passed }),
|
||||
async request(method, raw, body) {
|
||||
calls.push({ method, path: raw, body: clone(body) })
|
||||
const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), write = method !== 'GET'
|
||||
const key = `${method}:${path}:${body?.idempotencyKey}`
|
||||
if (write) {
|
||||
assert.ok(body?.idempotencyKey, raw)
|
||||
if (ledger.has(key)) {
|
||||
const old = ledger.get(key); assert.equal(JSON.stringify(body), old.body, 'replay must use original body and version')
|
||||
if (fault === 'replay-row-drift' && path.endsWith('/items')) orders.get('DS-1').items[0].dispatchItemId = '9999'
|
||||
const data = clone(old.data); if (data?.action || data?.vehicles) data.replayed = true; return response(data)
|
||||
}
|
||||
state = { ...responseFor(remaining), items: remaining }
|
||||
return ok(state)
|
||||
}
|
||||
if (path === `${base}/pre-arrange`) {
|
||||
if (new Set(body.planPalletIds).size !== body.planPalletIds.length) return error('A0400')
|
||||
if (body.draftToken !== 'DRAFT') return error('A0409')
|
||||
preArrangeCount++
|
||||
const picked = [...body.planPalletIds].sort().map(palletId => rows.find(row => row.planPalletId === palletId))
|
||||
const items = picked.map(row => state.items.find(item => item.planPalletId === row.planPalletId) || { ...row, id: String(nextItemId++) })
|
||||
state = { ...responseFor(picked), items }
|
||||
if (fault === 'replay-id-drift' && preArrangeCount === 2) state.items[0].id = String(nextItemId++)
|
||||
return ok(state)
|
||||
const remember = data => { ledger.set(key, { body: JSON.stringify(body), data: clone(data) }); return response(data) }
|
||||
const draft = drafts.get(body?.draftToken)
|
||||
if (method === 'POST' && path === `${BASE}/drafts`) { const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', sources: [], containers: [] }; drafts.set(d.draftToken, d); return remember(d) }
|
||||
if (method === 'GET' && path === `${BASE}/drafts`) return response([...drafts.values()].filter(d => d.status === 'ACTIVE'))
|
||||
if (path === `${BASE}/candidates`) {
|
||||
assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED')
|
||||
const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey)))
|
||||
for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey)
|
||||
return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' })
|
||||
}
|
||||
throw new Error(`unexpected request ${method} ${path}`)
|
||||
if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) }
|
||||
const deleteContainer = path.match(/\/drafts\/([^/]+)\/containers\/(\d+)$/)
|
||||
if (deleteContainer) { const d = drafts.get(deleteContainer[1]); assert.equal(body.recordVersion, d.recordVersion); const c = d.containers.find(c => c.draftContainerId === deleteContainer[2]); if (c.sourceKeys.length) return response(null, 'A0409'); d.containers = d.containers.filter(c => c.draftContainerId !== deleteContainer[2]); d.recordVersion++; return remember(d) }
|
||||
if (path === `${BASE}/draft-locks/lock` || path === `${BASE}/draft-locks/unlock`) {
|
||||
assert.equal(body.recordVersion, draft.recordVersion)
|
||||
const c = candidates.find(c => c.selection.planPalletId === body.source.planPalletId), container = draft.containers.find(g => g.draftContainerId === body.draftContainerId)
|
||||
assert.deepEqual(body.source, c.selection)
|
||||
if (path.endsWith('/lock')) {
|
||||
if ([...orders.values()].some(o => o.items.some(i => i.sourceKey === c.sourceKey))) return response(null, 'A0409')
|
||||
draft.sources.push(clone(c)); container.sourceKeys.push(c.sourceKey)
|
||||
} else { draft.sources = draft.sources.filter(s => s.sourceKey !== c.sourceKey); container.sourceKeys = container.sourceKeys.filter(k => k !== c.sourceKey) }
|
||||
draft.recordVersion++; return remember(draft)
|
||||
}
|
||||
if (path === `${BASE}/draft-locks/release`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(draft.sources.length, 0); draft.status = 'RELEASED'; draft.recordVersion++; return remember(draft) }
|
||||
if (path === `${BASE}/drafts/snapshot`) { assert.equal(body.recordVersion, draft.recordVersion); assert.ok(Array.isArray(body.planIds)); assert.ok(Array.isArray(body.containers)); draft.containers = clone(body.containers); draft.recordVersion++; return remember(draft) }
|
||||
if (path === `${BASE}/drafts/pre-arrange`) {
|
||||
assert.equal(body.recordVersion, draft.recordVersion)
|
||||
if (!body.groups.every(g => JSON.stringify(g.dispatch.cargo.map(s => `PLAN:${s.planPalletId}`).sort()) === JSON.stringify([...draft.containers.find(c => c.draftContainerId === g.draftContainerId).sourceKeys].sort()))) return response(null, 'A0409')
|
||||
const vehicles = body.groups.map(g => ({ draftContainerId: g.draftContainerId, dispatch: command(create(g.dispatch.cargo), 'CREATE') })); draft.status = 'CONVERTED'
|
||||
return remember({ draftToken: draft.draftToken, vehicles, replayed: false })
|
||||
}
|
||||
if (path === BASE && method === 'POST') { assert.equal(body.vehicle, null); assert.equal(body.driver, null); return remember(command(create(body.cargo), 'CREATE')) }
|
||||
if (path === BASE && method === 'GET') return response({ records: [...orders.values()].filter(o => o.dispatchNo === params.get('dispatchNo')).map(o => ({ ...command(o, 'READ'), warehouseId: '44' })) })
|
||||
const match = path.slice(BASE.length).match(/^\/([^/]+)(?:\/(items|route)(?:\/([^/]+)\/remove)?)?$/), o = orders.get(match?.[1]); assert.ok(o, `${method} ${raw}`)
|
||||
if (write) {
|
||||
assert.equal(body.recordVersion, o.recordVersion, 'explicit server version, including zero')
|
||||
if (match[2] === 'route') { assert.equal(method, 'PUT'); assert.deepEqual([...body.destinationKeys].sort(), [...o.keys].sort()); o.keys = clone(body.destinationKeys); o.recordVersion++; return remember(command(o, 'UPDATE_ROUTE')) }
|
||||
if (match[3]) {
|
||||
if (!o.items.some(i => i.dispatchItemId === match[3])) { if (fault === 'rejected-remove-mutates') o.recordVersion++; return response(null, 'A0404') }
|
||||
o.items = o.items.filter(i => i.dispatchItemId !== match[3]); o.recordVersion++; syncRoute(o); return remember(command(o, 'REMOVE_CARGO'))
|
||||
}
|
||||
assert.equal(method, 'POST'); assert.equal(match[2], 'items')
|
||||
if (new Set(body.cargo.map(s => s.planPalletId)).size !== body.cargo.length) return response(null, 'A0400')
|
||||
o.items.push(...body.cargo.map(s => item(candidates.find(c => c.selection.planPalletId === s.planPalletId)))); o.recordVersion++; syncRoute(o)
|
||||
if (fault === 'unknown-add') return { http: 0, body: null }
|
||||
if (fault === 'route-drift') o.keys = ['FBA:FOREIGN']
|
||||
return remember(command(o, 'ADD_CARGO'))
|
||||
}
|
||||
if (match[2] === 'items') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: o.items })
|
||||
if (match[2] === 'route') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, stops: stops(o), edit: { allowed: true } })
|
||||
return response(view(o))
|
||||
} }
|
||||
return { context, calls, checks, options: { dispatchNo: 'DS-TEST', candidates: selected, duplicate: rows[2], draftToken: 'DRAFT', draftContainerId: '50' } }
|
||||
if (initial) { create(candidates.slice(0, 2).map(c => c.selection)); context.createdResources.phase4OverseasDispatch = {} }
|
||||
return { context, candidates, calls, checks, orders, bundle: () => { const o = orders.get('DS-1'); return { data: view(o), warehouseId: '44', items: { items: clone(o.items) }, route: { stops: stops(o) } } } }
|
||||
}
|
||||
|
||||
test('C52 HTTP orchestration adds duplicate destination, removes last destination, restores and freezes exact replay/rejection facts', async () => {
|
||||
const h = lifecycleHarness()
|
||||
await verifyDispatchRouteLifecycle(h.context, h.options)
|
||||
assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED')
|
||||
assert.ok(h.checks.length >= 20 && h.checks.every(row => row.passed))
|
||||
for (const label of ['同目的地第二托增加箱托数但路线稳定去重', '最后一托移除后路线删除该目的地', '重新加入后恢复原目的地集合', '重复托盘ID拒绝A0400', '其他草稿抢占原排车拒绝A0409']) {
|
||||
assert.ok(h.checks.some(row => row.name.includes(label)))
|
||||
}
|
||||
assert.ok(h.calls.filter(row => row.path.endsWith('/pre-arrange')).every(row => !Object.hasOwn(row.body, 'route')))
|
||||
assert.equal(h.calls.filter(row => row.method === 'DELETE').length, 3)
|
||||
assert.deepEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages.at(-1).snapshot.items.map(row => row.planPalletId), ['20', '30'])
|
||||
test('native route fixture uses real physical pallets and requires two destinations plus a repeated destination', () => {
|
||||
const c = rows(), selected = selectDispatchRouteFixture(c)
|
||||
assert.deepEqual(selected.selected.map(c => c.sourceKey), ['PLAN:21', 'PLAN:22']); assert.equal(selected.duplicate.sourceKey, 'PLAN:23')
|
||||
assert.equal(selectDispatchRouteFixture(c.slice(0, 2)), null)
|
||||
c[2].selection.warehousePalletId = c[0].selection.warehousePalletId; assert.equal(selectDispatchRouteFixture(c), null)
|
||||
})
|
||||
|
||||
test('C52 stops on wrong route, replay row identity drift or side effects after rejected removal', async () => {
|
||||
for (const fault of ['wrong-route', 'replay-id-drift', 'rejected-delete-mutates']) {
|
||||
const h = lifecycleHarness(fault)
|
||||
await assert.rejects(verifyDispatchRouteLifecycle(h.context, h.options), /C52/)
|
||||
assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'UNPROVEN')
|
||||
assert.ok(h.checks.some(row => !row.passed))
|
||||
test('native route proof rejects drift in actual identity, physical scope, counts and destination keys', () => {
|
||||
const h = server('', true), original = h.bundle(), expected = h.candidates.slice(0, 2)
|
||||
assert.equal(dispatchRouteMatches(original, expected), true)
|
||||
for (const mutate of [b => { b.warehouseId = '999' }, b => { b.items.items[0].customerId = '999' }, b => { b.items.items[0].warehousePalletId = '999' }, b => { b.items.items[0].sourceVersion = '0'.repeat(64) }, b => { b.data.counts.packageCount++ }, b => { b.route.stops[1].destinationKey = 'FBA:OTHER' }, b => { b.data.dispatchInfo.route = 'A1,A1,A2' }, b => { b.items.items[1].dispatchItemId = b.items.items[0].dispatchItemId }, b => { b.data.orderDetails[0].customerId = '999' }, b => { b.data.orderDetails[0].sourceKey = 'PLAN:999' }, b => { b.data.orderDetails[0].destinationFbaWarehouse = 'OTHER' }]) { const bad = clone(original); mutate(bad); assert.equal(dispatchRouteMatches(bad, expected), false) }
|
||||
const changed = clone(original); changed.items.items[0].dispatchItemId = '9999'; assert.notEqual(dispatchBusinessSnapshot(changed), dispatchBusinessSnapshot(original))
|
||||
})
|
||||
|
||||
test('C52 uses native versioned cargo commands, immutable replay, actual readback and route keys', async () => {
|
||||
const h = server('', true), current = await verifyDispatchRouteLifecycle(h.context, { dispatchNo: 'DS-1', candidates: h.candidates.slice(0, 2), duplicate: h.candidates[2] })
|
||||
assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED'); assert.ok(h.checks.every(c => c.passed))
|
||||
assert.equal(current.data.counts.packageCount, 5); assert.equal(current.data.recordVersion, 6)
|
||||
const firstAdd = h.calls.find(c => c.method === 'POST' && c.path.endsWith('/items')); assert.equal(firstAdd.body.recordVersion, 0)
|
||||
assert.deepEqual(h.calls.filter(c => c.method === 'PUT').map(c => c.body.destinationKeys), [['FBA:A2', 'FBA:A1'], ['FBA:A2', 'FBA:A1'], ['FBA:A1', 'FBA:A2'], ['FBA:A1', 'FBA:A2']])
|
||||
assert.equal(h.calls.some(c => c.method === 'DELETE'), false)
|
||||
})
|
||||
|
||||
test('C52 rejects replay row drift, rejection side effects, wrong route and unknown writes without cleanup', async () => {
|
||||
for (const fault of ['replay-row-drift', 'rejected-remove-mutates', 'route-drift', 'unknown-add']) {
|
||||
const h = server(fault, true)
|
||||
await assert.rejects(verifyDispatchRouteLifecycle(h.context, { dispatchNo: 'DS-1', candidates: h.candidates.slice(0, 2), duplicate: h.candidates[2] }))
|
||||
assert.notEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED')
|
||||
assert.equal(h.calls.some(c => c.method === 'PUT' || c.method === 'DELETE'), false)
|
||||
if (fault === 'unknown-add') { assert.equal(h.context.createdResources.dispatchWriteBoundary.outcomeUnknown, true); assert.equal(h.calls.filter(c => c.method !== 'GET').length, 1); assert.equal(h.orders.get('DS-1').items.length, 3) }
|
||||
}
|
||||
})
|
||||
|
||||
test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => {
|
||||
assert.match(source, /相同草稿快照重复保存/)
|
||||
assert.match(source, /同草稿重复锁定托盘幂等成功/)
|
||||
assert.match(source, /其他草稿锁定同一托盘返回A0409/)
|
||||
assert.match(source, /出库前同草稿重复预排复用原排车且业务快照不漂移/)
|
||||
assert.match(source, /重复释放草稿幂等成功/)
|
||||
assert.match(source, /排车详情按记录自身客户范围加载托盘明细/)
|
||||
assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s)
|
||||
test('phase4 missing current marker or real multi-destination fixture is explicit NOT_RUN before writes', async () => {
|
||||
const h = server(); h.context.createdResources.phase4OverseasPda.marker = 'OLD'; assert.equal((await run(h.context)).status, 'NOT_RUN'); assert.equal(h.calls.length, 0)
|
||||
const other = server(); other.candidates.pop(); assert.equal((await run(other.context)).status, 'NOT_RUN'); assert.equal(other.calls.filter(c => c.method !== 'GET').length, 0)
|
||||
const ambiguous = server(); ambiguous.candidates[2].appointmentBatches = [{}, {}]; assert.equal((await run(ambiguous.context)).status, 'NOT_RUN'); assert.equal(ambiguous.calls.filter(c => c.method !== 'GET').length, 0)
|
||||
})
|
||||
|
||||
test('module does not invent optimistic version or idempotency headers absent from DispatchRequests', () => {
|
||||
assert.doesNotMatch(source, /expectedVersion|recordVersion|Idempotency-Key/)
|
||||
assert.match(source, /PENDING_DEPARTURE/)
|
||||
assert.match(source, /data == null/)
|
||||
})
|
||||
|
||||
test('module records the marker-owned dispatch without attempting excluded cleanup actions', () => {
|
||||
assert.match(source, /createdResources\.phase4OverseasDispatch/)
|
||||
assert.match(source, /dispatchNo = dispatchNo/)
|
||||
assert.match(source, /finalStatus = arranged\.data\?\.status/)
|
||||
assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/)
|
||||
})
|
||||
|
||||
test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => {
|
||||
assert.match(source, /phase4OverseasRelabel/)
|
||||
assert.match(source, /sourceStage === 'RELABEL'/)
|
||||
assert.match(source, /relabel\.targetPalletIds\.includes/)
|
||||
assert.match(source, /relabelCartonMarks/)
|
||||
assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/)
|
||||
assert.match(source, /relabelNo: relabel\.relabelNo/)
|
||||
})
|
||||
|
||||
test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => {
|
||||
assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/)
|
||||
const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources')))
|
||||
assert.doesNotMatch(branch, /\breturn\b/)
|
||||
assert.match(source, /Object.assign\(relabel, \{/)
|
||||
})
|
||||
|
||||
test('dispatch replay snapshot ignores regenerated item ids but detects business drift', () => {
|
||||
const a = { id: '1', dispatchNo: 'DS-1', status: 'PENDING_DEPARTURE', packageCount: 2,
|
||||
palletCount: 1, items: [{ id: '10', planPalletId: '20', warehousePalletId: '30', packageCount: 2 }] }
|
||||
const replay = { ...a, items: [{ ...a.items[0], id: '11' }] }
|
||||
assert.equal(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(replay))
|
||||
for (const changed of [{ ...replay, id: '2' }, { ...replay, packageCount: 4 },
|
||||
{ ...replay, items: [{ ...a.items[0], warehousePalletId: '31' }] }]) {
|
||||
assert.notEqual(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(changed))
|
||||
test('complete offline phase4 hands off the actual native Detail and current source keys at BEFORE_LOADING', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'phase4-dispatch-contract-'))
|
||||
try {
|
||||
const h = server(); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') }
|
||||
h.context.createdResources.dispatchWriteBoundary = { marker: 'CURRENT', stopped: false, password: 'PRIVATE_PASSWORD', token: 'PRIVATE_LOGIN_TOKEN' }
|
||||
const originalRequest = h.context.request
|
||||
h.context.request = async (method, path, body) => {
|
||||
if (method !== 'GET') {
|
||||
const evidence = JSON.parse(await readFile(join(directory, 'phase4-dispatch-checkpoints', 'CURRENT.json'), 'utf8'))
|
||||
const intents = [...(evidence.phase4OverseasDispatch.requests || []), ...(evidence.groupedDispatch?.requests || []), ...(evidence.phase4OverseasDispatch.routeLifecycle?.requests || [])]
|
||||
assert.ok(intents.some(r => r.status === 'SENDING' && r.method === method && r.path === path && JSON.stringify(r.body) === JSON.stringify(body)), 'original write must already exist on disk')
|
||||
}
|
||||
return originalRequest(method, path, body)
|
||||
}
|
||||
const proof = await run(h.context)
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.finalStatus, 'ACTIVE'); assert.equal(proof.stage, 'BEFORE_LOADING'); assert.ok(h.checks.every(c => c.passed))
|
||||
assert.deepEqual(proof.sourceKeys, ['PLAN:21', 'PLAN:22']); assert.deepEqual(proof.destinationWarehouseCodes, ['A1', 'A2']); assert.equal(proof.warehouseId, '44')
|
||||
const actual = await h.context.request('GET', `${BASE}/${proof.dispatchNo}`); assert.deepEqual(proof.data, actual.body.data)
|
||||
assert.equal(Object.hasOwn(proof.data, 'items'), false); assert.equal(Object.hasOwn(proof.data, 'route'), false); assert.equal(Object.hasOwn(proof, 'dispatchId'), false)
|
||||
assert.equal(Object.hasOwn(proof, 'loadingMeasurements'), false); assert.equal(proof.loadingEvidenceStatus, 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS')
|
||||
assert.equal(h.calls.some(c => /\/review|\/pick|\/scan|\/loading|\/depart|\/transfer-delivery/.test(c.path)), false)
|
||||
assert.equal(h.calls.some(c => c.path === `${BASE}/pre-arrange` || c.body?.containersJson), false)
|
||||
const text = await readFile(proof.checkpointPath, 'utf8'), journal = JSON.parse(text)
|
||||
assert.equal(journal.phase4OverseasDispatch.status, 'VERIFIED'); assert.ok(journal.phase4OverseasDispatch.draftToken)
|
||||
assert.equal(text.includes('PRIVATE_PASSWORD'), false); assert.equal(text.includes('PRIVATE_LOGIN_TOKEN'), false)
|
||||
await assert.rejects(dispatchEvidenceContext({ marker: 'CURRENT', options: h.context.options, createdResources: { phase4OverseasDispatch: {} } }), /已存在本轮排车检查点/)
|
||||
const stale = server('append-only-route'); await assert.rejects(run(stale.context), /最后实物移出后真实数量归零/)
|
||||
assert.equal(stale.context.createdResources.phase4OverseasDispatch.status, 'UNPROVEN'); assert.equal(stale.orders.size, 1)
|
||||
assert.equal(stale.calls.some(c => c.path.endsWith('/drafts')), false)
|
||||
} finally {
|
||||
assert.ok(basename(directory).startsWith('phase4-dispatch-contract-')); assert.equal(resolve(directory, '..'), resolve(tmpdir()))
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
assert.doesNotMatch(source, /item\?\.cartonMarks/)
|
||||
})
|
||||
|
||||
test('relabel creation cannot prematurely settle fulfillment or fabricate loading measurements', () => {
|
||||
const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8')
|
||||
assert.equal(source.includes('settleRelabelFulfillment'), false)
|
||||
assert.equal(source.includes('vehicleAppointmentStatus'), false)
|
||||
assert.equal(source.includes('loadingMeasurements:'), false)
|
||||
assert.ok(source.includes('fixture.targetPalletIds')); assert.ok(source.includes('READY_FOR_OUTBOUND'))
|
||||
})
|
||||
|
||||
@@ -1,440 +1,197 @@
|
||||
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
|
||||
|
||||
import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
import { DISPATCH_BASE as BASE, id, canonical, version, success, DispatchNotRun, sourceSelection, createBody, markerCandidates,
|
||||
notRun, dispatchIo, dispatchEvidenceContext, versionAfter, query, candidatePage, replay, readDispatch, dispatchMatches, newDraft, activeDraft, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-dispatch'
|
||||
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
|
||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||
const BASE = '/api/platform/v1/last-leg/dispatches'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
|
||||
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
export function dispatchBusinessSnapshot(value) {
|
||||
return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
route: value?.route, packageCount: value?.packageCount, palletCount: value?.palletCount,
|
||||
items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId),
|
||||
warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage,
|
||||
packageCount: item.packageCount, inboundCode: item.inboundCode,
|
||||
destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode }))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
})
|
||||
}
|
||||
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
|
||||
function trace(response) {
|
||||
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
|
||||
}
|
||||
function query(path, values) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(values || {})) {
|
||||
if (value !== null && value !== undefined && String(value) !== '') params.set(key, String(value))
|
||||
}
|
||||
const encoded = params.toString()
|
||||
return encoded ? `${path}?${encoded}` : path
|
||||
}
|
||||
|
||||
async function api(context, method, name, path, body) {
|
||||
const response = await context.request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
|
||||
return { response, data }
|
||||
}
|
||||
|
||||
function blocker(context, name, evidence, response = null) {
|
||||
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
|
||||
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
|
||||
}
|
||||
|
||||
function markerFixture(context) {
|
||||
const fixture = context.createdResources?.phase4OverseasPda
|
||||
if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null
|
||||
const palletIds = new Set([...(fixture.palletIds || []),
|
||||
...Object.values(fixture.generatedPalletsByInbound || {}).flat().filter(row =>
|
||||
id(row.warehouseId) === id(fixture.overseasWarehouseId)).map(row => row.palletId)].map(id).filter(Boolean))
|
||||
if (!fixture.overseasWarehouseId || palletIds.size === 0) return null
|
||||
return { ...fixture, palletIds }
|
||||
}
|
||||
|
||||
const destination = row => String(row?.fbaWarehouseCode || row?.destinationWarehouseCode || '').trim()
|
||||
const destination = candidate => candidate?.destination?.warehouseCode
|
||||
const same = (a, b) => canonical(a) === canonical(b)
|
||||
const sorted = values => [...values].sort()
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right)
|
||||
// Item IDs are included here: editable pre-arrange retains existing rows, and rejected
|
||||
// commands must preserve the exact assigned facts, not merely their counts.
|
||||
export function dispatchRouteSnapshot(value) {
|
||||
return { id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
route: value?.route ?? '', warehouseId: id(value?.overseasWarehouseId),
|
||||
itemCount: value?.itemCount, palletCount: value?.palletCount, packageCount: value?.packageCount,
|
||||
pickedPackageCount: value?.pickedPackageCount, shippedPackageCount: value?.shippedPackageCount,
|
||||
items: (value?.items || []).map(row => ({ id: id(row.id), planPalletId: id(row.planPalletId),
|
||||
warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo,
|
||||
warehouseId: id(row.overseasWarehouseId), inboundCode: row.inboundCode,
|
||||
orderNo: row.orderNo, orderRefs: row.orderRefs, sourceStage: row.sourceStage,
|
||||
destinationWarehouseCode: row.destinationWarehouseCode, fbaWarehouseCode: row.fbaWarehouseCode,
|
||||
packageCount: row.packageCount })).sort((a, b) => a.planPalletId.localeCompare(b.planPalletId)) }
|
||||
// 读取对象保持三个原生响应独立,不给Detail补回旧items/route字段。
|
||||
export function dispatchRouteSnapshot(bundle) {
|
||||
return { dispatchNo: bundle?.data?.dispatchNo, status: bundle?.data?.status, stage: bundle?.data?.stage,
|
||||
recordVersion: bundle?.data?.recordVersion, warehouseId: bundle?.warehouseId, counts: bundle?.data?.counts,
|
||||
route: bundle?.data?.dispatchInfo?.route, stops: bundle?.route?.stops,
|
||||
items: bundle?.items?.items?.map(i => ({ dispatchItemId: i.dispatchItemId, sourceKey: i.sourceKey, sourceVersion: i.sourceVersion,
|
||||
customerId: i.customerId, planPalletId: i.planPalletId, warehousePalletId: i.warehousePalletId,
|
||||
destination: i.destination, inboundCode: i.inboundCode, scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })).sort((a, b) => a.sourceKey.localeCompare(b.sourceKey)) }
|
||||
}
|
||||
|
||||
export function dispatchRouteMatches(value, expectedCandidates) {
|
||||
if (!Array.isArray(value?.items) || !Array.isArray(expectedCandidates)) return false
|
||||
const items = value.items, route = String(value.route || '').trim()
|
||||
const routeParts = route ? route.split(',').map(part => part.trim()) : []
|
||||
const actualDestinations = sorted(new Set(items.map(destination)))
|
||||
return value.status === 'PENDING_DEPARTURE'
|
||||
&& Number(value.itemCount) === items.length && Number(value.palletCount) === items.length
|
||||
&& Number(value.packageCount) === items.reduce((total, row) => total + Number(row.packageCount), 0)
|
||||
&& items.length === expectedCandidates.length
|
||||
&& new Set(items.map(row => id(row.planPalletId))).size === items.length
|
||||
&& new Set(items.map(row => id(row.warehousePalletId))).size === items.length
|
||||
&& items.every(row => /^[1-9][0-9]*$/.test(id(row.id)) && destination(row)
|
||||
&& expectedCandidates.some(expected => id(expected.planPalletId) === id(row.planPalletId)
|
||||
&& id(expected.warehousePalletId) === id(row.warehousePalletId)
|
||||
&& id(expected.overseasWarehouseId) === id(row.overseasWarehouseId)
|
||||
&& id(value.overseasWarehouseId) === id(row.overseasWarehouseId)
|
||||
&& expected.sourceStage === row.sourceStage && expected.inboundCode === row.inboundCode
|
||||
&& Number(expected.packageCount) === Number(row.packageCount) && Number(row.packageCount) > 0
|
||||
&& destination(expected) === destination(row)))
|
||||
&& routeParts.every(Boolean) && routeParts.length === new Set(routeParts).size
|
||||
&& same(sorted(routeParts), actualDestinations)
|
||||
export function dispatchBusinessSnapshot(bundle) { return canonical(dispatchRouteSnapshot(bundle)) }
|
||||
export function dispatchRouteMatches(bundle, candidates) {
|
||||
if (!dispatchMatches(bundle, candidates) || !Array.isArray(bundle.route?.stops) || bundle.route.stops[0]?.index !== 0
|
||||
|| bundle.route.stops[0].destinationKey !== `ORIGIN:${bundle.warehouseId}`) return false
|
||||
const codes = String(bundle.data.dispatchInfo?.route || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
const expected = sorted(new Set(candidates.map(destination)))
|
||||
const keys = bundle.route.stops.slice(1).map(s => s.destinationKey)
|
||||
return expected.every(Boolean) && codes.length === new Set(codes).size && same(sorted(codes), expected)
|
||||
&& keys.length === new Set(keys).size && same(sorted(keys), sorted(new Set(candidates.map(c => c.destination.key))))
|
||||
}
|
||||
|
||||
export function selectDispatchRouteFixture(candidates) {
|
||||
const unique = [...new Map(candidates.map(row => [id(row.warehousePalletId), row])).values()]
|
||||
const groups = new Map()
|
||||
for (const row of unique) {
|
||||
const code = destination(row)
|
||||
if (code) groups.set(code, [...(groups.get(code) || []), row])
|
||||
for (const candidate of new Map(candidates.map(c => [id(c.selection?.warehousePalletId), c])).values()) {
|
||||
const code = destination(candidate); if (code) groups.set(code, [...(groups.get(code) || []), candidate])
|
||||
}
|
||||
const repeated = [...groups.values()].find(rows => rows.length >= 2)
|
||||
if (groups.size < 2 || !repeated) return null
|
||||
return { selected: [...groups.values()].map(rows => rows[0]), duplicate: repeated[1] }
|
||||
const other = [...groups.values()].find(rows => rows !== repeated)
|
||||
return groups.size >= 2 && repeated ? { selected: [repeated[0], other[0]], duplicate: repeated[1] } : null
|
||||
}
|
||||
|
||||
export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate, draftToken, draftContainerId }) {
|
||||
const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = {
|
||||
marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [],
|
||||
}
|
||||
const detailPath = `${BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
let identity = null
|
||||
const check = (name, response, passed, evidence) => {
|
||||
context.assert(MODULE, `C52 ${name}`, response, passed, evidence, OPTIONS)
|
||||
if (!passed) throw new Error(`C52 ${name}`)
|
||||
}
|
||||
const read = async (name, expected) => {
|
||||
const current = await api(context, 'GET', `C52 ${name}回查实际分配明细`, detailPath)
|
||||
const snapshot = dispatchRouteSnapshot(current.data)
|
||||
const passed = success(current.response) && current.data?.dispatchNo === dispatchNo
|
||||
&& (!identity || snapshot.id === identity) && dispatchRouteMatches(current.data, expected)
|
||||
proof.stages.push({ name, passed, snapshot, expectedPlanPalletIds: expected.map(row => id(row.planPalletId)),
|
||||
requestId: current.response?.requestId, traceId: current.response?.traceId })
|
||||
check(`${name}实际明细独立推导路线及本轮来源核平`, current.response, passed, JSON.stringify(snapshot))
|
||||
identity ||= snapshot.id
|
||||
return current.data
|
||||
}
|
||||
const lock = async row => {
|
||||
const result = await api(context, 'POST', 'C52 新增本轮托盘到原草稿锁', `${BASE}/draft-locks/lock`,
|
||||
{ planPalletId: id(row.planPalletId), draftToken, draftContainerId })
|
||||
check('新增草稿锁成功', result.response, success(result.response), trace(result.response))
|
||||
}
|
||||
const arrange = async (name, selected) => {
|
||||
const body = { planPalletIds: selected.map(row => id(row.planPalletId)), draftToken }
|
||||
const result = await api(context, 'POST', `C52 ${name}`, `${BASE}/pre-arrange`, body)
|
||||
check(`${name}复用原排车`, result.response, success(result.response) && result.data?.dispatchNo === dispatchNo, trace(result.response))
|
||||
const current = await read(name, selected)
|
||||
const replay = await api(context, 'POST', `C52 ${name}同草稿同载荷重放`, `${BASE}/pre-arrange`, body)
|
||||
const after = await read(`${name}重放后`, selected)
|
||||
check(`${name}重放路线及完整明细零漂移`, replay.response, success(replay.response)
|
||||
&& same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(replay.data))
|
||||
&& same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(replay.response))
|
||||
return after
|
||||
}
|
||||
const rejectUnchanged = async (name, method, path, body, expectedCode, current, selected) => {
|
||||
const result = await context.request(method, path, body)
|
||||
// These business errors use the current gateway envelope and must retain traces.
|
||||
check(name, result, result?.http === 200 && codeOf(result) === expectedCode
|
||||
&& Boolean(result.requestId) && Boolean(result.traceId), trace(result))
|
||||
const after = await read(`${name}后`, selected)
|
||||
check(`${name}路线数量及明细零漂移`, result,
|
||||
same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(result))
|
||||
}
|
||||
const initial = await read('初始多目的地', candidates)
|
||||
const expanded = [...candidates, duplicate]
|
||||
await lock(duplicate)
|
||||
const added = await arrange('新增同目的地第二托', expanded)
|
||||
check('同目的地第二托增加箱托数但路线稳定去重', null,
|
||||
initial.route === added.route && added.palletCount === initial.palletCount + 1,
|
||||
`before=${initial.route}, after=${added.route}`)
|
||||
const extraItem = added.items.find(row => id(row.planPalletId) === id(duplicate.planPalletId))
|
||||
const removeExtra = await api(context, 'DELETE', 'C52 移除同目的地第二托', `${detailPath}/items/${id(extraItem.id)}`)
|
||||
const dedup = await read('移除同目的地第二托', candidates)
|
||||
check('同目的地仍有托盘时路线不消失', removeExtra.response, success(removeExtra.response) && dedup.route === initial.route, trace(removeExtra.response))
|
||||
const removedCandidate = candidates.find(row => destination(row) !== destination(duplicate))
|
||||
const removedItem = dedup.items.find(row => id(row.planPalletId) === id(removedCandidate.planPalletId))
|
||||
const reducedCandidates = candidates.filter(row => row !== removedCandidate)
|
||||
const removePath = `${detailPath}/items/${id(removedItem.id)}`
|
||||
const removed = await api(context, 'DELETE', 'C52 移除某目的地最后一托', removePath)
|
||||
check('移除最后一托请求成功', removed.response, success(removed.response), trace(removed.response))
|
||||
const reduced = await read('移除目的地最后一托', reducedCandidates)
|
||||
check('最后一托移除后路线删除该目的地', null,
|
||||
!reduced.route.split(',').map(part => part.trim()).includes(destination(removedCandidate)), `route=${reduced.route}`)
|
||||
await rejectUnchanged('重复移除已删除明细A0404', 'DELETE', removePath, undefined, 'A0404', reduced, reducedCandidates)
|
||||
await lock(removedCandidate)
|
||||
const restored = await arrange('重新加入已移除目的地', candidates)
|
||||
check('重新加入后恢复原目的地集合', null,
|
||||
same(sorted(restored.route.split(',')), sorted(initial.route.split(','))), `route=${restored.route}`)
|
||||
const body = { planPalletIds: candidates.map(row => id(row.planPalletId)), draftToken }
|
||||
await rejectUnchanged('重复托盘ID拒绝A0400', 'POST', `${BASE}/pre-arrange`,
|
||||
{ ...body, planPalletIds: [...body.planPalletIds, body.planPalletIds[0]] }, 'A0400', restored, candidates)
|
||||
await rejectUnchanged('其他草稿抢占原排车拒绝A0409', 'POST', `${BASE}/pre-arrange`,
|
||||
{ ...body, draftToken: `${context.marker}-C52-FOREIGN`.slice(0, 64) }, 'A0409', restored, candidates)
|
||||
const freed = await api(context, 'GET', 'C52 移除重复目的地托盘后候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
check('额外托盘正式锁已释放', freed.response, success(freed.response) && Array.isArray(freed.data)
|
||||
&& freed.data.some(row => id(row.planPalletId) === id(duplicate.planPalletId)
|
||||
&& id(row.warehousePalletId) === id(duplicate.warehousePalletId)), trace(freed.response))
|
||||
proof.status = 'VERIFIED'
|
||||
async function unchangedAfterRejection(io, method, path, body, code, before, dispatchNo, candidates) {
|
||||
const result = await io.send(method, path, body, `拒绝${code}`)
|
||||
io.check(`业务错误${code}有诊断回执`, result, [200, Number(code.slice(2))].includes(result?.http)
|
||||
&& String(result.body?.code) === code && Boolean(result.requestId) && Boolean(result.traceId))
|
||||
const after = await readDispatch(io, dispatchNo)
|
||||
io.check('拒绝后路线、版本及实物零漂移', null, dispatchRouteMatches(after, candidates) && dispatchBusinessSnapshot(after) === dispatchBusinessSnapshot(before))
|
||||
return after
|
||||
}
|
||||
|
||||
async function runRelabelDispatch(context, relabel) {
|
||||
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
|
||||
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
|
||||
const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : [])
|
||||
.filter((value) => value?.sourceStage === 'RELABEL'
|
||||
&& relabel.targetPalletIds.includes(id(value?.warehousePalletId)))
|
||||
const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '')
|
||||
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
||||
context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response,
|
||||
candidates.length === relabel.targetPalletIds.length
|
||||
&& relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark))
|
||||
&& candidates.every((value) => !String(value?.cartonMarks || '').trim()),
|
||||
`pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`,
|
||||
OPTIONS)
|
||||
if (candidates.length !== relabel.targetPalletIds.length) return
|
||||
|
||||
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
|
||||
fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
|
||||
})
|
||||
const dispatchNo = id(arranged.data?.dispatchNo)
|
||||
context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response,
|
||||
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
|
||||
&& Number(arranged.data?.palletCount) === candidates.length
|
||||
&& Array.isArray(arranged.data?.items)
|
||||
&& arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'),
|
||||
`dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`,
|
||||
OPTIONS)
|
||||
if (!dispatchNo) return
|
||||
Object.assign(relabel, {
|
||||
dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null,
|
||||
relabelNo: relabel.relabelNo, planPalletId: null,
|
||||
})
|
||||
const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
// DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks
|
||||
// through OutboundDetail.pallets after transferring this exact dispatch.
|
||||
context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response,
|
||||
success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data),
|
||||
trace(detail.response), OPTIONS)
|
||||
// C52只使用当前货物命令与路线命令,绝不借旧预排对已建主单做隐式编辑。
|
||||
export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate }) {
|
||||
const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], requests: [] }
|
||||
const io = dispatchIo(context, MODULE, proof, 'C52'), path = `${BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
const read = async (label, expected) => {
|
||||
const current = await readDispatch(io, dispatchNo)
|
||||
io.check(label, null, dispatchRouteMatches(current, expected)); proof.stages.push({ label, snapshot: dispatchRouteSnapshot(current) }); return current
|
||||
}
|
||||
const mutate = async (action, suffix, fields, expected, method = 'POST') => {
|
||||
const before = await readDispatch(io, dispatchNo)
|
||||
const body = { recordVersion: version(before.data.recordVersion), idempotencyKey: io.key(action), ...fields }
|
||||
const result = await io.command(method, `${path}${suffix}`, body, action)
|
||||
io.check(`${action}恢复同一整车命令身份`, null, result?.dispatchNo === dispatchNo && versionAfter(result.recordVersion, before.data.recordVersion))
|
||||
const current = await read(action, expected)
|
||||
await replay(io, `${path}${suffix}`, body, result, `${action}原键重放`, method)
|
||||
io.check(`${action}重放不漂移实际行身份`, null, dispatchBusinessSnapshot(current) === dispatchBusinessSnapshot(await readDispatch(io, dispatchNo)))
|
||||
return current
|
||||
}
|
||||
try {
|
||||
if (markerCandidates(context, [...candidates, duplicate]).length !== candidates.length + 1) throw new DispatchNotRun('C52来源不属于本轮客户仓')
|
||||
const initial = await read('初始多目的地', candidates), expanded = [...candidates, duplicate]
|
||||
const extra = createBody(context.marker, 'C52-extra', [duplicate])
|
||||
let current = await mutate('新增同目的地第二托', '/items', { cargo: extra.cargo, batchAllocations: extra.batchAllocations }, expanded)
|
||||
io.check('同目的地第二托增加箱托数但路线稳定去重', null, initial.data.dispatchInfo.route === current.data.dispatchInfo.route && Number(current.data.counts.palletCount) === Number(initial.data.counts.palletCount) + 1)
|
||||
const item = current.items.items.find(i => i.sourceKey === duplicate.sourceKey)
|
||||
current = await mutate('移除同目的地第二托', `/items/${encodeURIComponent(item.dispatchItemId)}/remove`, { reason: `${context.marker}-释放额外测试实物` }, candidates)
|
||||
const removed = candidates.find(c => destination(c) !== destination(duplicate)), row = current.items.items.find(i => i.sourceKey === removed.sourceKey)
|
||||
const reduced = candidates.filter(c => c.sourceKey !== removed.sourceKey), removePath = `${path}/items/${encodeURIComponent(row.dispatchItemId)}/remove`
|
||||
current = await mutate('移除目的地最后一托', `/items/${encodeURIComponent(row.dispatchItemId)}/remove`, { reason: `${context.marker}-路线核验` }, reduced)
|
||||
io.check('最后一托移除后路线删除该目的地', null, !String(current.data.dispatchInfo.route).split(',').includes(destination(removed)))
|
||||
await unchangedAfterRejection(io, 'POST', removePath, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('missing-item'), reason: '明确缺失明细' }, 'A0404', current, dispatchNo, reduced)
|
||||
const add = createBody(context.marker, 'C52-restore', [removed])
|
||||
current = await mutate('重新加入已移除目的地', '/items', { cargo: add.cargo, batchAllocations: add.batchAllocations }, candidates)
|
||||
const duplicateSource = sourceSelection(candidates[0])
|
||||
await unchangedAfterRejection(io, 'POST', `${path}/items`, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('duplicate-source'), cargo: [duplicateSource, duplicateSource], batchAllocations: [] }, 'A0400', current, dispatchNo, candidates)
|
||||
const otherIo = dispatchIo(context, MODULE, proof, 'C52-FOREIGN')
|
||||
let other = await newDraft(otherIo, candidates[0].warehouseId)
|
||||
other = await otherIo.command('POST', `${BASE}/drafts/containers`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('container'), draftToken: other.draftToken, capacity: 15 }, '创建本轮独立冲突草稿容器')
|
||||
await unchangedAfterRejection(io, 'POST', `${BASE}/draft-locks/lock`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('lock-conflict'), draftToken: other.draftToken, draftContainerId: other.containers[0].draftContainerId, source: sourceSelection(candidates[0]) }, 'A0409', current, dispatchNo, candidates)
|
||||
const releasedBody = { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('release'), draftToken: other.draftToken }
|
||||
const released = await otherIo.command('POST', `${BASE}/draft-locks/release`, releasedBody, '释放确认未取得来源的冲突草稿')
|
||||
await replay(otherIo, `${BASE}/draft-locks/release`, releasedBody, released, '释放原键重放')
|
||||
const originalStops = current.route.stops, keys = originalStops.slice(1).map(s => s.destinationKey)
|
||||
current = await mutate('重排实际卸货顺序', '/route', { destinationKeys: [...keys].reverse(), reason: `${context.marker}-明确既有站序` }, candidates, 'PUT')
|
||||
io.check('真实起点保留且卸货顺序反转', null, same(current.route.stops[0], originalStops[0]) && same(current.route.stops.slice(1).map(s => s.destinationKey), [...keys].reverse()))
|
||||
current = await mutate('恢复实际卸货顺序', '/route', { destinationKeys: keys, reason: `${context.marker}-恢复原站序` }, candidates, 'PUT')
|
||||
io.check('原实际站序恢复', null, same(current.route.stops, originalStops))
|
||||
const available = await candidatePage(io, candidates[0].warehouseId)
|
||||
io.check('额外实物已释放为真实候选', null, available.some(c => c.sourceKey === duplicate.sourceKey && c.blockReasons?.length === 0))
|
||||
proof.status = 'VERIFIED'; return current
|
||||
} catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; throw error }
|
||||
}
|
||||
async function removeControl(context, io, candidate) {
|
||||
const body = createBody(context.marker, 'REMOVAL-CONTROL', [candidate])
|
||||
const command = await io.command('POST', BASE, body, '正式Create创建独立移货对照')
|
||||
await replay(io, BASE, body, command, '正式创建原键恢复')
|
||||
let current = await readDispatch(io, command.dispatchNo)
|
||||
io.check('对照车来自本轮真实来源', null, dispatchRouteMatches(current, [candidate]))
|
||||
const item = current.items.items[0]
|
||||
const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('control-remove'), reason: `${context.marker}-未拣实物释放` }
|
||||
await io.command('POST', `${BASE}/${encodeURIComponent(command.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '移出原生明细')
|
||||
current = await readDispatch(io, command.dispatchNo)
|
||||
io.check('最后实物移出后真实数量归零', null, dispatchRouteMatches(current, [])); return command.dispatchNo
|
||||
}
|
||||
async function prepareDraft(context, io, candidates) {
|
||||
let draft = await newDraft(io, candidates[0].warehouseId)
|
||||
const change = async (action, path, fields, method = 'POST') => {
|
||||
const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(action), draftToken: draft.draftToken, ...fields }
|
||||
draft = await io.command(method, path, body, action); await replay(io, path, body, draft, `${action}原键重放`, method); return draft
|
||||
}
|
||||
await change('临时容器', `${BASE}/drafts/containers`, { capacity: 15 })
|
||||
const temporary = draft.containers[0].draftContainerId, source = sourceSelection(candidates[0])
|
||||
await change('临时选货', `${BASE}/draft-locks/lock`, { draftContainerId: temporary, source })
|
||||
const occupied = await activeDraft(io, draft.draftToken)
|
||||
const invalid = await io.send('DELETE', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('nonempty-delete'), reason: '非空容器删除负例' }, '非空容器拒绝')
|
||||
io.check('非空容器不能删除', invalid, [200, 409].includes(invalid?.http) && String(invalid.body?.code) === 'A0409')
|
||||
io.check('拒绝删除后草稿不变', null, same(await activeDraft(io, draft.draftToken), occupied))
|
||||
await change('解除原容器来源', `${BASE}/draft-locks/unlock`, { draftContainerId: temporary, source })
|
||||
const free = await candidatePage(io, candidates[0].warehouseId)
|
||||
io.check('解锁恢复真实候选', null, free.some(c => c.sourceKey === candidates[0].sourceKey && c.blockReasons?.length === 0))
|
||||
await change('删除已空容器', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { reason: `${context.marker}-空容器` }, 'DELETE')
|
||||
await change('正式容器', `${BASE}/drafts/containers`, { capacity: 15 })
|
||||
const container = draft.containers[0].draftContainerId
|
||||
for (const [n, candidate] of candidates.entries()) await change(`正式选货-${n}`, `${BASE}/draft-locks/lock`, { draftContainerId: container, source: sourceSelection(candidate) })
|
||||
await change('完整布局', `${BASE}/drafts/snapshot`, { planIds: [], containers: draft.containers })
|
||||
const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken })
|
||||
io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
|
||||
const hidden = await candidatePage(io, candidates[0].warehouseId)
|
||||
io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
|
||||
return draft
|
||||
}
|
||||
async function createRelabel(context, io, proof) {
|
||||
const fixture = context.createdResources?.phase4OverseasRelabel
|
||||
if (fixture?.marker !== context.marker || fixture.status !== 'READY_FOR_OUTBOUND') return
|
||||
const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL' })
|
||||
const candidates = page.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo && id(c.customerId) === id(fixture.customerId)
|
||||
&& id(c.warehouseId) === id(fixture.overseasWarehouseId) && fixture.targetPalletIds?.map(id).includes(id(c.selection.warehousePalletId)))
|
||||
if (!candidates.length || candidates.length !== fixture.targetPalletIds.length) { proof.relabel = { status: 'NOT_RUN', reason: '没有本轮完整换标申请来源' }; return }
|
||||
const body = createBody(context.marker, 'RELABEL', candidates), created = await io.command('POST', BASE, body, '真实换标整单Create')
|
||||
await replay(io, BASE, body, created, '换标Create原键恢复')
|
||||
const current = await readDispatch(io, created.dispatchNo)
|
||||
io.check('整单换标来源、客户仓及数量回查', null, dispatchMatches(current, candidates))
|
||||
Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data })
|
||||
proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
context.createdResources.phase4OverseasDispatch = {
|
||||
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
|
||||
planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], finalStatus: null,
|
||||
cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded',
|
||||
context.createdResources ??= {}
|
||||
if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建')
|
||||
const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] }
|
||||
try {
|
||||
const fixture = context.createdResources.phase4OverseasPda
|
||||
markerCandidates(context, [])
|
||||
let io = dispatchIo(context, MODULE, proof, 'MAIN')
|
||||
const page = await candidatePage(io, fixture.overseasWarehouseId)
|
||||
const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page))
|
||||
if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本')
|
||||
const selected = routeFixture.selected
|
||||
;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c]))
|
||||
context = await dispatchEvidenceContext(context)
|
||||
io = dispatchIo(context, MODULE, proof, 'MAIN')
|
||||
proof.removalDispatchNo = await removeControl(context, io, selected[0])
|
||||
const grouped = await verifyGroupedDispatch(context, MODULE, selected)
|
||||
if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason)
|
||||
const draft = await prepareDraft(context, io, selected)
|
||||
proof.draftToken = draft.draftToken
|
||||
const groups = [{ draftContainerId: draft.containers[0].draftContainerId, dispatch: createBody(context.marker, 'MAIN-CHILD', draft.sources) }]
|
||||
const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups }
|
||||
const converted = await io.command('POST', `${BASE}/drafts/pre-arrange`, body, '真实草稿容器转换主链整车')
|
||||
io.check('转换返回原服务器草稿和唯一子命令', null, converted?.draftToken === draft.draftToken && converted.vehicles?.length === 1 && converted.vehicles[0].dispatch?.action === 'CREATE')
|
||||
const dispatchNo = converted.vehicles[0].dispatch.dispatchNo
|
||||
let current = await readDispatch(io, dispatchNo)
|
||||
io.check('转换成功后回查真实来源', null, dispatchRouteMatches(current, selected))
|
||||
await replay(io, `${BASE}/drafts/pre-arrange`, body, converted, '主链转换原键恢复')
|
||||
const active = await io.command('GET', `${BASE}/drafts`, undefined, '转换后不再出现在活动草稿')
|
||||
io.check('已转换草稿退出活动列表', null, Array.isArray(active) && !active.some(d => d.draftToken === draft.draftToken))
|
||||
current = await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selected, duplicate: routeFixture.duplicate })
|
||||
Object.assign(proof, { dispatchNo, warehouseId: current.warehouseId, customerId: fixture.customerId,
|
||||
customerIds: sorted(new Set(current.items.items.map(i => i.customerId))), inboundCodes: sorted(new Set(current.items.items.map(i => i.inboundCode))),
|
||||
planPalletIds: selected.map(c => c.selection.planPalletId), destinationWarehouseCodes: sorted(new Set(current.data.orderDetails.map(o => o.destinationFbaWarehouse))),
|
||||
sourceKeys: sorted(new Set(current.data.orderDetails.map(o => o.sourceKey))), recordVersion: current.data.recordVersion,
|
||||
finalStatus: current.data.status, stage: current.data.stage, data: current.data, items: current.items, route: current.route,
|
||||
sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' })
|
||||
io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING')
|
||||
await createRelabel(context, io, proof)
|
||||
await context.checkpoint()
|
||||
} catch (error) {
|
||||
if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message)
|
||||
proof.outcomeUnknown ||= context.createdResources.dispatchWriteBoundary?.outcomeUnknown === true
|
||||
proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message
|
||||
try { await context.checkpoint?.() } catch (persistError) { proof.persistenceError = persistError.message }
|
||||
throw error
|
||||
}
|
||||
|
||||
const initialPage = await api(context, 'GET', '查询海外排车分页', query(BASE, { page: 1, size: 20 }))
|
||||
context.assert(MODULE, '排车分页包络稳定', initialPage.response,
|
||||
Number.isFinite(Number(initialPage.data?.page)) && Number.isFinite(Number(initialPage.data?.size))
|
||||
&& Number.isFinite(Number(initialPage.data?.total)) && Array.isArray(initialPage.data?.records),
|
||||
`records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS)
|
||||
if (!success(initialPage.response)) return
|
||||
|
||||
const relabel = context.createdResources?.phase4OverseasRelabel
|
||||
if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') {
|
||||
await runRelabelDispatch(context, relabel)
|
||||
if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel)
|
||||
}
|
||||
|
||||
const fixture = markerFixture(context)
|
||||
if (!fixture) {
|
||||
blocker(context, '取得本轮海外PDA已收货托盘前置',
|
||||
`marker=${context.marker}, pdaMarker=${context.createdResources?.phase4OverseasPda?.marker || 'missing'}, status=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选',
|
||||
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : []
|
||||
const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId))
|
||||
&& id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId)
|
||||
&& id(value?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA'
|
||||
&& id(value?.planId) && id(value?.planPalletId)
|
||||
&& value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0)
|
||||
const routeFixture = selectDispatchRouteFixture(markerCandidates)
|
||||
if (!routeFixture) {
|
||||
blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据')
|
||||
return
|
||||
}
|
||||
const selectedCandidates = routeFixture.selected
|
||||
const candidatesByDestination = new Map(selectedCandidates.map(row => [destination(row), row]))
|
||||
const candidate = selectedCandidates[0]
|
||||
context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response,
|
||||
selectedCandidates.length >= 2,
|
||||
`candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS)
|
||||
if (selectedCandidates.length < 2) return
|
||||
|
||||
const planId = id(candidate.planId)
|
||||
const planPalletId = id(candidate.planPalletId)
|
||||
const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))]
|
||||
const planPalletIds = selectedCandidates.map(value => id(value.planPalletId))
|
||||
// Exercise removal on a disposable dispatch, then reuse the released current-run pallet.
|
||||
const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: [planPalletId],
|
||||
})
|
||||
const removableNo = id(removable.data?.dispatchNo)
|
||||
const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId)
|
||||
if (!success(removable.response) || !removableNo || !removableItem?.id) {
|
||||
blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response)
|
||||
return
|
||||
}
|
||||
const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}`
|
||||
const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath)
|
||||
const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`)
|
||||
context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response,
|
||||
success(removed.response) && success(removedDetail.response)
|
||||
&& Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0
|
||||
&& removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS)
|
||||
const removedAgain = await context.request('DELETE', removePath)
|
||||
context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain,
|
||||
removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS)
|
||||
const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
context.assert(MODULE, '正式锁释放后候选恢复', freed.response,
|
||||
Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS)
|
||||
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
|
||||
await verifyGroupedDispatch(context, MODULE, selectedCandidates)
|
||||
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
|
||||
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
|
||||
const snapshotBody = { draftToken, planIds, containersJson }
|
||||
context.createdResources.phase4OverseasDispatch.draftToken = draftToken
|
||||
context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId
|
||||
context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds
|
||||
context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()]
|
||||
|
||||
const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody)
|
||||
context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response,
|
||||
success(snapshot.response) && (snapshot.response.body?.data == null), trace(snapshot.response), OPTIONS)
|
||||
if (!success(snapshot.response)) return
|
||||
const snapshotReplay = await api(context, 'POST', '相同草稿快照重复保存', `${BASE}/drafts/snapshot`, snapshotBody)
|
||||
if (!success(snapshotReplay.response)) return
|
||||
|
||||
const draftsResult = await api(context, 'GET', '回查当前操作员智能预排草稿', `${BASE}/drafts`)
|
||||
const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : []
|
||||
const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken)
|
||||
context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response,
|
||||
matchingDrafts.length === 1 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value))
|
||||
&& matchingDrafts[0].containersJson === containersJson,
|
||||
`matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS)
|
||||
|
||||
const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 })
|
||||
const containerId = id(disposableContainer.data?.id)
|
||||
if (!success(disposableContainer.response) || !containerId) return
|
||||
const boundBody = { planPalletId, draftToken, draftContainerId: containerId }
|
||||
const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody)
|
||||
if (!success(bound.response)) return
|
||||
const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`)
|
||||
context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response,
|
||||
boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container =>
|
||||
id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS)
|
||||
await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody)
|
||||
const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
context.assert(MODULE, '草稿解锁恢复候选', unlocked.response,
|
||||
unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS)
|
||||
await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody)
|
||||
await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
|
||||
const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`)
|
||||
context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response,
|
||||
Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS)
|
||||
const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 })
|
||||
if (!success(finalContainer.response) || !finalContainer.data?.id) return
|
||||
const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) }))
|
||||
for (const [index, body] of lockBodies.entries()) {
|
||||
const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body)
|
||||
if (!success(locked.response)) return
|
||||
}
|
||||
const lockBody = lockBodies[0]
|
||||
const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody)
|
||||
context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response,
|
||||
success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS)
|
||||
|
||||
const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选',
|
||||
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken }))
|
||||
const visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId)))
|
||||
const visible = planPalletIds.every(value => visibleIds.has(value))
|
||||
context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible,
|
||||
`planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS)
|
||||
|
||||
const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘',
|
||||
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId)))
|
||||
const hidden = planPalletIds.every(value => !hiddenIds.has(value))
|
||||
context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden,
|
||||
`planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS)
|
||||
|
||||
const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64)
|
||||
const foreignLock = await context.request('POST', `${BASE}/draft-locks/lock`, {
|
||||
planPalletId, draftToken: foreignToken,
|
||||
})
|
||||
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
|
||||
|
||||
const preArrangeBody = {
|
||||
planPalletIds, fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken,
|
||||
}
|
||||
const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
const dispatchNo = id(arranged.data?.dispatchNo)
|
||||
context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response,
|
||||
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
|
||||
&& Array.isArray(arranged.data?.items)
|
||||
&& planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value))
|
||||
&& Number(arranged.data?.palletCount) === planPalletIds.length,
|
||||
`dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${trace(arranged.response)}`, OPTIONS)
|
||||
if (!dispatchNo) return
|
||||
context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo
|
||||
context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id)
|
||||
context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null
|
||||
|
||||
const duplicateArrange = await context.request('POST', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange,
|
||||
success(duplicateArrange)
|
||||
&& dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data),
|
||||
trace(duplicateArrange), OPTIONS)
|
||||
|
||||
const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, {
|
||||
page: 1, size: 20, dispatchNo,
|
||||
}))
|
||||
const row = records(pageResult.data).find((value) => id(value?.dispatchNo) === dispatchNo)
|
||||
context.assert(MODULE, '排车分页包含本轮marker预排且状态未推进', pageResult.response,
|
||||
row?.status === 'PENDING_DEPARTURE' && id(row?.id) === id(arranged.data?.id),
|
||||
`dispatchNo=${dispatchNo}, status=${row?.status || 'missing'}; ${trace(pageResult.response)}`, OPTIONS)
|
||||
|
||||
const detailResult = await api(context, 'GET', '查询本轮排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
context.assert(MODULE, '排车详情按记录自身客户范围加载托盘明细', detailResult.response,
|
||||
id(detailResult.data?.dispatchNo) === dispatchNo && detailResult.data?.status === 'PENDING_DEPARTURE'
|
||||
&& Array.isArray(detailResult.data?.items)
|
||||
&& detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId),
|
||||
`dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS)
|
||||
|
||||
await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selectedCandidates,
|
||||
duplicate: routeFixture.duplicate, draftToken, draftContainerId: id(finalContainer.data.id) })
|
||||
|
||||
const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken })
|
||||
if (success(released.response)) {
|
||||
const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken })
|
||||
context.assert(MODULE, '重复释放不影响正式排车锁且Void响应可为空', releaseReplay.response,
|
||||
success(releaseReplay.response) && releaseReplay.response.body?.data == null, trace(releaseReplay.response), OPTIONS)
|
||||
}
|
||||
|
||||
const afterRelease = await api(context, 'GET', '释放后回查活动草稿', `${BASE}/drafts`)
|
||||
context.assert(MODULE, '释放后当前token不再出现在活动草稿', afterRelease.response,
|
||||
Array.isArray(afterRelease.data) && !afterRelease.data.some((value) => value?.draftToken === draftToken),
|
||||
`draftToken=${draftToken}; ${trace(afterRelease.response)}`, OPTIONS)
|
||||
return proof
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test'
|
||||
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
||||
createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
|
||||
dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs'
|
||||
dispatchDestinationConservation, pickAll, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
@@ -27,27 +29,26 @@ test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才
|
||||
}
|
||||
})
|
||||
|
||||
test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{
|
||||
const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']}
|
||||
const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'},
|
||||
{planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]}
|
||||
const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}]
|
||||
assert.equal(dispatchDestinationConservation(detail,fixture),true)
|
||||
assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true)
|
||||
for(const mutate of [
|
||||
value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER',
|
||||
value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER',
|
||||
value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED',
|
||||
value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10',
|
||||
value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(),
|
||||
value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}),
|
||||
value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}),
|
||||
]) {
|
||||
const value=structuredClone({detail,fixture,deliveries});mutate(value)
|
||||
assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false)
|
||||
test('native route conserves source keys and destinations while same destination may contain multiple customers', () => {
|
||||
const fixture = { sourceKeys: ['PLAN:10', 'PLAN:11', 'PLAN:12'], destinationWarehouseCodes: ['ONT8', 'LAX9'] }
|
||||
const detail = { dispatchInfo: { route: 'ONT8,LAX9' }, orderDetails: [
|
||||
{ sourceKey: 'PLAN:10', customerId: '100', destinationFbaWarehouse: 'ONT8' },
|
||||
{ sourceKey: 'PLAN:11', customerId: '200', destinationFbaWarehouse: 'LAX9' },
|
||||
{ sourceKey: 'PLAN:12', customerId: '200', destinationFbaWarehouse: 'ONT8' }] }
|
||||
const deliveries = [{ customerId: '100', destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } },
|
||||
{ customerId: '200', destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } },
|
||||
{ customerId: '200', destination: { key: 'FBA:LAX9', warehouseCode: 'LAX9' } }]
|
||||
assert.equal(dispatchDestinationConservation(detail, fixture), true)
|
||||
assert.equal(dispatchDestinationConservation(detail, fixture, deliveries), true)
|
||||
for (const mutate of [value => { value.detail.dispatchInfo.route = 'ONT8' },
|
||||
value => { value.detail.dispatchInfo.route = 'ONT8,ONT8,LAX9' },
|
||||
value => { value.detail.orderDetails[1].destinationFbaWarehouse = 'OTHER' },
|
||||
value => { value.detail.orderDetails[1].sourceKey = 'PLAN:99' },
|
||||
value => { value.fixture.destinationWarehouseCodes.push('UNSELECTED') },
|
||||
value => { value.deliveries.pop() }, value => { value.deliveries.push(value.deliveries[0]) }]) {
|
||||
const value = structuredClone({ detail, fixture, deliveries }); mutate(value)
|
||||
assert.equal(dispatchDestinationConservation(value.detail, value.fixture, value.deliveries), false)
|
||||
}
|
||||
assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/)
|
||||
assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/)
|
||||
})
|
||||
|
||||
test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => {
|
||||
@@ -61,40 +62,38 @@ test('phase-five fulfillment is a registered default HTTP module after the real
|
||||
}
|
||||
})
|
||||
|
||||
test('phase-five module covers dispatch edit, transfer, PDA pick/depart and delivery state', () => {
|
||||
for (const token of [
|
||||
"const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
|
||||
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'",
|
||||
'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick',
|
||||
'/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}',
|
||||
'/delivery-orders/${encodeURIComponent(currentNo)}/status',
|
||||
]) assert.ok(source.includes(token), token)
|
||||
assert.match(source, /WAIT_OUTBOUND/)
|
||||
assert.match(source, /WAIT_DELIVERY/)
|
||||
assert.match(source, /READY_TO_DEPART/)
|
||||
assert.match(source, /OUTBOUNDED/)
|
||||
assert.match(source, /DELIVERING/)
|
||||
assert.match(source, /expectedVersion: deliveryVersion/)
|
||||
assert.match(source, /transferDeliveries\(transferred\.data\)/)
|
||||
assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/)
|
||||
assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/)
|
||||
test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => {
|
||||
for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
|
||||
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", '/transfer-delivery', '/delivery-arrangement',
|
||||
'cargoHash: value.cargoHash', 'loadingCompletionHash: value.loadingCompletionHash',
|
||||
'recordVersion: version(detail.data.recordVersion)', 'positionId: id',
|
||||
'departed.data.result?.shippedPackages', 'outboundRef.outboundNo']) {
|
||||
if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/)
|
||||
else assert.ok(source.includes(token), token)
|
||||
}
|
||||
assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/)
|
||||
assert.match(source, /data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'/)
|
||||
assert.match(source, /departed.data.stage === 'DELIVERING'/)
|
||||
const run = source.slice(source.indexOf('export async function run'))
|
||||
assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll('))
|
||||
assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading('))
|
||||
assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await arrangeDispatchDelivery('))
|
||||
assert.ok(run.indexOf('await arrangeDispatchDelivery(') < run.indexOf('await verifyDepartureReadiness('))
|
||||
assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip('))
|
||||
assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest'))
|
||||
})
|
||||
|
||||
test('phase-five module proves the client delivery create, list and nested detail contract', () => {
|
||||
assert.match(source, /const MERCHANT_DELIVERY_BASE = '\/api\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
test('client reads its own native-arranged deliveries and full nested document contract', () => {
|
||||
assert.match(source, /loginMerchant\(context, customerId\)/)
|
||||
assert.match(source, /context\.options\?\.merchantUsername/)
|
||||
assert.match(source, /\/api\/merchant\/v1\/iam\/tenant\/profile/)
|
||||
assert.match(source, /id\(profile\?\.id\) === customerId/)
|
||||
assert.match(source, /客户创建派送并原子转出库单/)
|
||||
assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/)
|
||||
assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/)
|
||||
assert.match(source, /context.options\?.merchantUsername/)
|
||||
assert.match(source, /id\(profile\?.id\) === customerId/)
|
||||
assert.match(source, /客户分页回查自身派送单/)
|
||||
assert.match(source, /客户读取自身派送详情/)
|
||||
assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/)
|
||||
assert.match(source, /merchantDetail\.data\?\.skus/)
|
||||
assert.match(source, /merchantDetail\.data\?\.fees/)
|
||||
assert.match(source, /merchantDetail\.data\?\.operations/)
|
||||
assert.match(source, /merchantDetail.data\?.order\?.deliveryNo/)
|
||||
assert.match(source, /merchantDetail.data\?.skus/)
|
||||
assert.match(source, /merchantDetail.data\?.fees/)
|
||||
assert.match(source, /merchantDetail.data\?.operations/)
|
||||
assert.doesNotMatch(source, /merchant\(context, merchantToken, 'POST'.*创建派送/)
|
||||
})
|
||||
|
||||
test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => {
|
||||
@@ -134,10 +133,9 @@ test('phase-five module uses marker-owned fixtures and always disables its tempo
|
||||
})
|
||||
|
||||
test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => {
|
||||
assert.match(source, /idempotencyKey: key\(context, 'TRANSFER'\)/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'TRANSFER', dispatchNo\)/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-COMPLETE'\)/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART'\)/)
|
||||
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART', dispatchNo\)/)
|
||||
assert.match(source, /addCoverageGap\(MODULE, SCENARIO/)
|
||||
assert.match(source, /deploymentBlocker: true/)
|
||||
})
|
||||
@@ -153,9 +151,10 @@ test('relabel marks are checked on the authoritative outbound DTO without weaken
|
||||
assert.match(source, /relabel\?\.marker !== context\.marker/)
|
||||
})
|
||||
|
||||
test('manual exception retries only a transport failure with the identical business body', () => {
|
||||
assert.match(source, /manualException\.response\.http === 0/)
|
||||
assert.match(source, /人工异常连接失败后使用同载荷重试/)
|
||||
test('unknown writes preserve the original command and stop instead of transport retry', () => {
|
||||
assert.match(source, /entry.status = 'UNKNOWN'/)
|
||||
assert.match(source, /存在未知写结果/)
|
||||
assert.doesNotMatch(source, /人工异常连接失败后使用同载荷重试/)
|
||||
assert.match(source, /manualExceptionBody/)
|
||||
})
|
||||
|
||||
@@ -497,34 +496,28 @@ test('inventory evidence captures zero drift independently for every balance buc
|
||||
}
|
||||
})
|
||||
|
||||
test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => {
|
||||
for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [
|
||||
['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true],
|
||||
['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false],
|
||||
['PICKING', true, 'READY_TO_DEPART', 'DS-1', false],
|
||||
['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false],
|
||||
['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false],
|
||||
['COMPLETED', true, 'DEPARTED', 'DS-1', false],
|
||||
['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false],
|
||||
]) {
|
||||
const context = contextFor((method, path) => path.startsWith('/api/pda/')
|
||||
? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus }))
|
||||
assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected)
|
||||
test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => {
|
||||
const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993',
|
||||
pickingReconciled: true, actions: { DEPART: { allowed: true } } }
|
||||
for (const change of [null, { stage: 'LOADING' }, { stage: 'DELIVERING' }, { status: 'CANCELLED' },
|
||||
{ dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false },
|
||||
{ actions: { DEPART: { allowed: false } } }]) {
|
||||
const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change }))
|
||||
assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), change == null)
|
||||
assert.equal(context.calls.length, 2)
|
||||
assert.equal(context.calls.some(call => call.method !== 'GET'), false)
|
||||
assert.equal(context.assertions.some(row => !row.passed), !expected)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => {
|
||||
const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [
|
||||
{ positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] }
|
||||
{ positions: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }, { positionId: '11', balanceId: '1' }] }] }
|
||||
assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'],
|
||||
sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] })
|
||||
assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null)
|
||||
assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null)
|
||||
assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null)
|
||||
assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ positionId: '11' }] }] }, 'DS-1', 'WH-1'), null)
|
||||
let shipped = false
|
||||
const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1',
|
||||
destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6,
|
||||
@@ -761,3 +754,129 @@ test('all fragments completed still requires raw aggregate history and matching
|
||||
? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args)
|
||||
assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false)
|
||||
})
|
||||
|
||||
|
||||
test('native PDA scans use exact current versions including zero, immutable replay and stale-version rejection without implicit loading', async () => {
|
||||
const values = ['0', '9007199254740993', '9007199254740994']
|
||||
let currentVersion = values[0], picked = 0, firstBody, firstResult
|
||||
const context = contextFor((method, path, body) => {
|
||||
if (method === 'GET') return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion,
|
||||
pickingReconciled: picked === 2, counts: { packageCount: 2, pickedPackageCount: picked },
|
||||
items: [{ dispatchItemId: '9007199254740995', customerId: '100', scheduledPackages: 2, pickedPackages: picked,
|
||||
positions: [{ positionId: '9007199254740996', balanceId: '9007199254740997', availablePackages: 1 },
|
||||
{ positionId: '9007199254740998', balanceId: '9007199254740999', availablePackages: 1 }] }] })
|
||||
assert.match(path, /items\/9007199254740995\/pick$/)
|
||||
assert.equal(Object.hasOwn(body, 'expectedVersion'), false)
|
||||
assert.equal(typeof body.positionId, 'string')
|
||||
if (body.idempotencyKey.endsWith('-STALE')) {
|
||||
assert.equal(body.recordVersion, '0'); return { http: 200, body: { code: 'A0409' } }
|
||||
}
|
||||
if (firstBody?.idempotencyKey === body.idempotencyKey) {
|
||||
assert.deepEqual(body, firstBody); return ok({ ...firstResult, replayed: true })
|
||||
}
|
||||
assert.equal(body.recordVersion, currentVersion)
|
||||
picked += 1; currentVersion = values[picked]
|
||||
firstBody = structuredClone(body)
|
||||
firstResult = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion,
|
||||
result: { dispatchItemId: '9007199254740995', pickedPackages: picked }, counts: { pickedPackageCount: picked } }
|
||||
return ok(firstResult)
|
||||
})
|
||||
assert.equal(await pickAll(context, 'PDA-IN-MEMORY', 'DS-1'), true)
|
||||
assert.equal(picked, 2)
|
||||
assert.equal(context.calls.some(call => /loading-complete|\/complete|\/depart/.test(call.path)), false)
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
})
|
||||
|
||||
function nativeArrangementPreview(recordVersion = '0') {
|
||||
return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'CARGO',
|
||||
loadingCompletionHash: 'LOAD', arrange: { allowed: true }, groups: ['100', '200'].map(customerId => ({
|
||||
groupKey: `GROUP-${customerId}`, customerId, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' },
|
||||
groupCargoHash: `GROUP-HASH-${customerId}`, externalFreightHash: 'FREIGHT', feeCoverageCargoHash: 'COVERAGE',
|
||||
requiredServices: ['DELIVERY_FEE'], feeSources: [], cargo: [{ customerId, packages: 1 }], blockReasons: [] })) }
|
||||
}
|
||||
|
||||
test('native arrangement carries frozen group and cost hashes, preserves all customer outbounds, and replays the exact original command', async () => {
|
||||
for (const recordVersion of ['0', '9007199254740993']) {
|
||||
const preview = nativeArrangementPreview(recordVersion)
|
||||
let original, result
|
||||
const context = contextFor((method, path, body) => {
|
||||
if (method === 'GET') { assert.match(path, /delivery-arrangement$/); return ok(preview) }
|
||||
assert.match(path, /transfer-delivery$/)
|
||||
if (original) { assert.deepEqual(body, original); return ok({ ...result, replayed: true }) }
|
||||
original = structuredClone(body)
|
||||
assert.equal(body.recordVersion, recordVersion)
|
||||
assert.equal(body.cargoHash, 'CARGO'); assert.equal(body.loadingCompletionHash, 'LOAD')
|
||||
assert.deepEqual(body.groups.map(row => row.groupKey), ['GROUP-100', 'GROUP-200'])
|
||||
assert.ok(body.groups.every(row => row.externalFreightHash === 'FREIGHT' && row.feeCoverageCargoHash === 'COVERAGE'))
|
||||
result = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740994',
|
||||
result: { dispatchNo: 'DS-1', cargoHash: 'CARGO', outbounds: ['100', '200'].map(customerId => ({
|
||||
customerId, outboundNo: `OB-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 1 })),
|
||||
deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`,
|
||||
deliveryNo: `DL-${customerId}`, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, status: 'WAIT_DELIVERY', packageCount: 1 })) } }
|
||||
return ok(result)
|
||||
})
|
||||
const arranged = await arrangeDispatchDelivery(context, 'DS-1')
|
||||
assert.deepEqual(arranged.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200'])
|
||||
assert.equal(context.calls.filter(call => call.method === 'POST').length, 2)
|
||||
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
||||
}
|
||||
})
|
||||
|
||||
test('arrangement cannot run before loading or with missing hashes, groups or server gate', async () => {
|
||||
for (const mutate of [value => { value.stage = 'LOADING' }, value => { value.status = 'CANCELLED' },
|
||||
value => { value.cargoHash = '' }, value => { value.loadingCompletionHash = '' },
|
||||
value => { value.groups = [] }, value => { value.arrange.allowed = false },
|
||||
value => { value.groups[0].blockReasons = ['MISSING_FEE'] }]) {
|
||||
const preview = nativeArrangementPreview(); mutate(preview)
|
||||
const context = contextFor((method) => { assert.equal(method, 'GET'); return ok(preview) })
|
||||
assert.equal(await arrangeDispatchDelivery(context, 'DS-1'), null)
|
||||
assert.ok(context.assertions.some(row => !row.passed))
|
||||
}
|
||||
})
|
||||
|
||||
for (const [name, outcome] of [
|
||||
['transport', { http: 0, body: { code: '' } }],
|
||||
['infrastructure', { http: 503, body: { code: 'S0503' } }],
|
||||
['malformed success', { http: 200, body: { code: '200', data: null } }],
|
||||
['missing business response', { http: 200, body: {} }],
|
||||
]) test(`write intent persists and ${name} stops commands and checkpoint restarts`, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'phase5-contract-'))
|
||||
try {
|
||||
const context = contextFor(async (method, path, body) => {
|
||||
const saved = JSON.parse(await readFile(context.createdResources.phase5OverseasFulfillment.checkpointPath, 'utf8'))
|
||||
assert.equal(saved.requests.at(-1).status, 'SENDING')
|
||||
assert.equal(saved.requests.at(-1).body.recordVersion, '0')
|
||||
assert.equal(saved.requests.at(-1).body.idempotencyKey, body.idempotencyKey)
|
||||
assert.equal(JSON.stringify(saved).includes('SECRET-CREDENTIAL'), false)
|
||||
return { ...outcome, requestId: 'REQ-UNKNOWN', traceId: 'TRACE-UNKNOWN' }
|
||||
})
|
||||
context.options.output = join(dir, 'report.json')
|
||||
context.createdResources.phase5OverseasFulfillment = {}
|
||||
const scoped = await fulfillmentEvidenceContext(context)
|
||||
const body = { recordVersion: '0', idempotencyKey: 'ORIGINAL-COMMAND', password: 'SECRET-CREDENTIAL' }
|
||||
await assert.rejects(scoped.request('POST', '/api/platform/v1/last-leg/dispatches/DS-1/depart', body), /结果未知/)
|
||||
await assert.rejects(scoped.request('POST', '/api/platform/v1/last-leg/dispatches/DS-1/depart', { ...body, idempotencyKey: 'NEW-KEY' }), /未知写结果/)
|
||||
assert.equal(context.calls.length, 1)
|
||||
const saved = JSON.parse(await readFile(context.createdResources.phase5OverseasFulfillment.checkpointPath, 'utf8'))
|
||||
assert.equal(saved.status, 'UNKNOWN'); assert.equal(saved.requests[0].status, 'UNKNOWN')
|
||||
assert.equal(saved.requests[0].requestId, 'REQ-UNKNOWN')
|
||||
await assert.rejects(fulfillmentEvidenceContext(context), /已存在本轮履约检查点/)
|
||||
} finally { await rm(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
|
||||
test('default normal completion returns before exception writes; explicit legacy return stays after its original receipt guards and before closure', () => {
|
||||
const run = source.slice(source.indexOf('export async function run'))
|
||||
const start = run.indexOf('if (context.context?.phase5TailVerification?.runReturnScenario !== true)')
|
||||
const exception = run.indexOf('const manualExceptionBody')
|
||||
assert.ok(start > run.indexOf("savedVisit.data?.visit?.status !== 'COMPLETED'"))
|
||||
const normalBranch = run.slice(start, exception)
|
||||
assert.ok(normalBranch.includes("finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'"))
|
||||
assert.match(normalBranch, /await finishFleetTrip/)
|
||||
assert.match(normalBranch, /return\s*}/)
|
||||
assert.doesNotMatch(normalBranch, /createDeliveryReturn|completeReturnedException|exception-orders|mark-return/)
|
||||
const explicitBranch = run.slice(exception)
|
||||
assert.ok(explicitBranch.indexOf('const returnReceipt = await completeReturnedException') < explicitBranch.indexOf('await finishFleetTrip'))
|
||||
assert.match(source, /const sourceBalanceIds = owned.inventoryProof\?.sourceBalanceIds/)
|
||||
assert.match(source, /原returnSource位置必须存在于正式排车冻结真实余额中/)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overs
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
||||
import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs'
|
||||
import { mkdir, writeFile, rename, readFile } from 'node:fs/promises'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'phase5-overseas-fulfillment'
|
||||
const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit'
|
||||
@@ -11,7 +14,86 @@ const LASTLEG_BASE = '/api/platform/v1/last-leg'
|
||||
const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'
|
||||
const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders'
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function id(value) {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('业务ID精度无效')
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
function version(value) {
|
||||
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value)))
|
||||
throw new Error('服务端recordVersion缺失或精度无效')
|
||||
return value
|
||||
}
|
||||
|
||||
// 发送前保存原命令;未知写结果只保留证据,禁止本轮继续推进。
|
||||
export async function fulfillmentEvidenceContext(context) {
|
||||
const marker = String(context.marker)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(marker)) throw new Error('验收marker不能用于独占检查点')
|
||||
const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase5-checkpoints', `${marker}.json`)
|
||||
try { await readFile(path); throw new Error(`已存在本轮履约检查点,必须核对原命令结果后续验:${path}`) }
|
||||
catch (error) { if (error.code !== 'ENOENT') throw error }
|
||||
const redact = value => {
|
||||
if (Array.isArray(value)) return value.map(redact)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
|
||||
.filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name))
|
||||
.map(([name, nested]) => [name, redact(nested)]))
|
||||
return value
|
||||
}
|
||||
const evidence = { marker, status: 'PREPARED', requests: [] }
|
||||
const scoped = Object.create(context)
|
||||
let stopped = false
|
||||
scoped.checkpoint = async () => {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence,
|
||||
fulfillment: context.createdResources.phase5OverseasFulfillment,
|
||||
fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8')
|
||||
await rename(`${path}.tmp`, path)
|
||||
}
|
||||
scoped.request = async (method, requestPath, body, ...rest) => {
|
||||
if (method === 'GET' || /\/auth\//.test(requestPath)) return context.request(method, requestPath, body, ...rest)
|
||||
const identityCleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(requestPath) && body?.status === 'DISABLED'
|
||||
if (stopped && !identityCleanup) throw new Error('存在未知写结果,保留原命令并停止后续写入')
|
||||
const entry = { method, path: requestPath, body: redact(body), status: 'SENDING' }
|
||||
evidence.requests.push(entry)
|
||||
await scoped.checkpoint()
|
||||
let response
|
||||
try { response = await context.request(method, requestPath, body, ...rest) }
|
||||
catch (error) {
|
||||
stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error
|
||||
}
|
||||
const nativeCommand = /^\/api\/(?:platform|pda)\/v1\/last-leg\/dispatches(?:\/|$)/.test(requestPath)
|
||||
const knownResponse = response?.http > 0 && response.http < 500 && response.body?.code != null
|
||||
&& !(nativeCommand && code(response) === '200' && response.body.data == null)
|
||||
Object.assign(entry, { status: knownResponse ? 'RESPONDED' : 'UNKNOWN',
|
||||
http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId,
|
||||
result: redact(response?.body?.data) })
|
||||
if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' }
|
||||
await scoped.checkpoint()
|
||||
if (stopped && !identityCleanup) throw new Error(`履约写入结果未知,停止并保留原命令;${trace(response)}`)
|
||||
return response
|
||||
}
|
||||
scoped.requestMultipart = async (method, requestPath, file, ...rest) => {
|
||||
const bytes = file?.content == null ? null : Buffer.from(file.content)
|
||||
const metadata = { name: file?.name, type: file?.type, size: bytes?.length,
|
||||
sha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null }
|
||||
// multipart也必须在发送前落盘;证据只含文件摘要而不含文件字节。
|
||||
const entry = { method, path: requestPath, file: metadata, status: 'SENDING' }
|
||||
if (stopped) throw new Error('未知写结果后禁止继续上传')
|
||||
evidence.requests.push(entry); await scoped.checkpoint()
|
||||
let response
|
||||
try { response = await context.requestMultipart(method, requestPath, file, ...rest) }
|
||||
catch (error) { stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error }
|
||||
Object.assign(entry, { status: response?.http > 0 && response.http < 500 ? 'RESPONDED' : 'UNKNOWN',
|
||||
http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId,
|
||||
result: redact(response?.body?.data) })
|
||||
if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' }
|
||||
await scoped.checkpoint()
|
||||
if (stopped) throw new Error(`上传结果未知,保留原文件摘要并停写;${trace(response)}`)
|
||||
return response
|
||||
}
|
||||
context.createdResources.phase5OverseasFulfillment.checkpointPath = path
|
||||
await scoped.checkpoint()
|
||||
return scoped
|
||||
}
|
||||
function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' }
|
||||
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
||||
function code(response) { return String(response?.body?.code ?? '') }
|
||||
@@ -52,7 +134,7 @@ async function merchant(context, token, method, name, path, body = undefined) {
|
||||
}
|
||||
|
||||
function transferDeliveries(data) {
|
||||
return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
|
||||
return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : []
|
||||
}
|
||||
|
||||
function deliveryNumbers(data) {
|
||||
@@ -63,21 +145,19 @@ export function dispatchDestinationConservation(detail, fixture, deliveries = nu
|
||||
const codes = values => values.map(value => id(value).trim().toUpperCase())
|
||||
const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank)
|
||||
&& right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort())
|
||||
const items = detail?.items
|
||||
if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds)
|
||||
|| !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false
|
||||
const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id)
|
||||
if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length
|
||||
|| new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false
|
||||
if (items.some(row => nonBlank(row.fbaWarehouseCode) && nonBlank(row.destinationWarehouseCode)
|
||||
&& id(row.fbaWarehouseCode).trim().toUpperCase() !== id(row.destinationWarehouseCode).trim().toUpperCase())) return false
|
||||
const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode))
|
||||
const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(','))
|
||||
const items = detail?.orderDetails
|
||||
if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.sourceKeys)
|
||||
|| !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.dispatchInfo?.route)) return false
|
||||
const actualIds = items.map(row => id(row.sourceKey)), expectedIds=fixture.sourceKeys.map(id)
|
||||
if (!actualIds.every(nonBlank) || !sameSet(actualIds,expectedIds)) return false
|
||||
const actual = codes(items.map(row => row.destinationFbaWarehouse))
|
||||
const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.dispatchInfo.route.split(','))
|
||||
if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false
|
||||
if (deliveries == null) return true
|
||||
if (!Array.isArray(deliveries) || !deliveries.length) return false
|
||||
const delivered=codes(deliveries.map(row => row.destinationWarehouseCode))
|
||||
return delivered.length === new Set(delivered).size && sameSet(delivered,actual)
|
||||
const delivered=codes(deliveries.map(row => row.destination?.warehouseCode))
|
||||
const groups = deliveries.map(row => `${id(row.customerId)}:${id(row.destination?.key)}`)
|
||||
return groups.every(nonBlank) && new Set(groups).size === groups.length && sameSet(delivered,actual)
|
||||
}
|
||||
|
||||
async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
|
||||
@@ -223,12 +303,12 @@ export function dispatchInventorySources(task, dispatchNo, warehouseId) {
|
||||
if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId
|
||||
|| !Array.isArray(task.items) || !task.items.length) return null
|
||||
const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : [])
|
||||
if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null
|
||||
if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.positionId)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null
|
||||
const references = new Map()
|
||||
for (const row of positions) {
|
||||
const previous = references.get(id(row.id))
|
||||
const previous = references.get(id(row.positionId))
|
||||
if (previous && previous !== id(row.balanceId)) return null
|
||||
references.set(id(row.id), id(row.balanceId))
|
||||
references.set(id(row.positionId), id(row.balanceId))
|
||||
}
|
||||
return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(),
|
||||
sourcePositionIds: [...references.keys()].sort(),
|
||||
@@ -312,7 +392,7 @@ export function dispatchStockConservation(context, { customerId, warehouseId, di
|
||||
const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks,
|
||||
snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } }
|
||||
if (context.createdResources?.phase5OverseasFulfillment) {
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof)
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof ||= {}, proof)
|
||||
}
|
||||
for (const [name, passed] of Object.entries(checks)) {
|
||||
context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS)
|
||||
@@ -517,14 +597,14 @@ export async function completeReturnedException(context, { customerId, warehouse
|
||||
id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode
|
||||
&& Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS)
|
||||
if (!location?.locationCode) return null
|
||||
let processingVersion = Number(order.version)
|
||||
let processingVersion = version(order.version)
|
||||
let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : ''
|
||||
if (!continuing) {
|
||||
const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' }
|
||||
const startBody = { expectedVersion: version(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' }
|
||||
const started = await platform(context, 'POST', '开始处理同一派送退回异常',
|
||||
query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody)
|
||||
if (!success(started.response) || started.data?.status !== 'PROCESSING') return null
|
||||
processingVersion = Number(started.data.version)
|
||||
processingVersion = version(started.data.version)
|
||||
const evidence = await context.requestMultipart('POST',
|
||||
query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }),
|
||||
{ name: `${context.marker}-return-receipt.txt`, type: 'text/plain',
|
||||
@@ -535,7 +615,7 @@ export async function completeReturnedException(context, { customerId, warehouse
|
||||
}
|
||||
const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey)
|
||||
? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE')
|
||||
if (!Number.isInteger(processingVersion) || processingVersion < 0) {
|
||||
if (processingVersion == null || !/^(0|[1-9]\d*)$/.test(String(processingVersion))) {
|
||||
blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response)
|
||||
return null
|
||||
}
|
||||
@@ -602,10 +682,12 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
const deliveryNo = id(delivery.deliveryNo)
|
||||
const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId })
|
||||
const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath)
|
||||
const version = Number(before.data?.version)
|
||||
const documentVersion = before.data?.version
|
||||
const validVersion = documentVersion != null && /^(0|[1-9]\d*)$/.test(String(documentVersion))
|
||||
&& (typeof documentVersion !== 'number' || Number.isSafeInteger(documentVersion))
|
||||
context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response,
|
||||
Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS)
|
||||
if (!success(before.response) || !Number.isInteger(version) || version < 0) return null
|
||||
validVersion, `version=${documentVersion ?? 'missing'}; ${trace(before.response)}`, OPTIONS)
|
||||
if (!success(before.response) || !validVersion) return null
|
||||
|
||||
const uploadResponse = await context.requestMultipart('POST',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), {
|
||||
@@ -620,7 +702,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
if (!success(uploadResponse) || !fileId) return null
|
||||
if (context.createdResources.phase5OverseasFulfillment) {
|
||||
const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= []
|
||||
receipts.push({ deliveryNo, fileId,
|
||||
receipts.push({ deliveryNo, customerId, fileId,
|
||||
content: `${context.marker} receipt acceptance for ${deliveryNo}` })
|
||||
}
|
||||
|
||||
@@ -629,7 +711,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)),
|
||||
})).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
|
||||
const saveBody = {
|
||||
expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)),
|
||||
expectedVersion: version(documentVersion), idempotencyKey: key(context, 'SAVE-RECEIPT', deliveryNo),
|
||||
bolNo: before.data?.bolNo || null,
|
||||
documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }],
|
||||
}
|
||||
@@ -641,7 +723,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde
|
||||
|
||||
const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody)
|
||||
context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response,
|
||||
savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version),
|
||||
savedReplay.data?.replayed === true && String(savedReplay.data?.version) === String(saved.data?.version),
|
||||
trace(savedReplay.response), OPTIONS)
|
||||
const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`,
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId }))
|
||||
@@ -768,18 +850,21 @@ export function relabelOutboundMarksMatch(detail, relabel) {
|
||||
&& Number(detail?.outbound?.packageCount) === expected.length
|
||||
}
|
||||
|
||||
async function verifyRelabelDispatchOutbound(context, relabel, transferBody) {
|
||||
async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId) {
|
||||
if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) {
|
||||
blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据')
|
||||
return
|
||||
}
|
||||
const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`,
|
||||
{ ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') })
|
||||
const outboundNo = transferred.data?.outboundNo
|
||||
if (!success(transferred.response) || !nonBlank(outboundNo)) return
|
||||
const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId)
|
||||
if (!await pickAll(context, token, relabel.dispatchNo)) return
|
||||
await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements)
|
||||
const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo)
|
||||
if (!transferred) return
|
||||
const customerId = id(relabel.customerId)
|
||||
const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo
|
||||
if (!nonBlank(outboundNo)) { blocker(context, '换标排车客户出库', '全车安排结果缺少本轮换标客户出库单'); return }
|
||||
const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛',
|
||||
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 }))
|
||||
`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`)
|
||||
context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response,
|
||||
success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo
|
||||
&& detail.data?.outbound?.dispatchNo === relabel.dispatchNo
|
||||
@@ -816,80 +901,138 @@ async function loginMerchant(context, customerId) {
|
||||
function fixture(context) {
|
||||
const dispatch = context.createdResources?.phase4OverseasDispatch
|
||||
const receiving = context.createdResources?.phase4OverseasPda
|
||||
if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE'
|
||||
if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'ACTIVE' || dispatch?.stage !== 'BEFORE_LOADING'
|
||||
|| !nonBlank(dispatch?.dispatchNo)) return null
|
||||
if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED'
|
||||
|| !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null
|
||||
|| !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)
|
||||
|| id(dispatch.warehouseId) !== id(receiving.overseasWarehouseId)) return null
|
||||
return { dispatch, receiving }
|
||||
}
|
||||
|
||||
export async function verifyDepartureReadiness(context, token, dispatchNo) {
|
||||
const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁',
|
||||
const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态',
|
||||
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const passed = success(ready.response) && success(dispatch.response)
|
||||
&& ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED'
|
||||
&& ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo
|
||||
&& dispatch.data?.status === 'READY_TO_DEPART'
|
||||
context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response,
|
||||
passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS)
|
||||
&& [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo
|
||||
&& data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'
|
||||
&& data.pickingReconciled === true && data.actions?.DEPART?.allowed === true)
|
||||
&& String(ready.data.recordVersion) === String(dispatch.data.recordVersion)
|
||||
context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response,
|
||||
passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS)
|
||||
if (passed) version(ready.data.recordVersion)
|
||||
return passed ? ready : null
|
||||
}
|
||||
|
||||
export async function pickAll(context, token, dispatchNo) {
|
||||
let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const items = Array.isArray(detail.data?.items) ? detail.data.items : []
|
||||
if (!success(detail.response) || items.length === 0) {
|
||||
blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response)
|
||||
if (!success(detail.response) || detail.data?.status !== 'ACTIVE' || detail.data?.stage !== 'LOADING'
|
||||
|| !Array.isArray(detail.data.items) || !detail.data.items.length) {
|
||||
blocker(context, 'PDA排车拣货任务', '本轮排车尚未通过两级审核进入LOADING', detail.response)
|
||||
return false
|
||||
}
|
||||
|
||||
for (const [itemIndex, item] of items.entries()) {
|
||||
let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0)
|
||||
const positions = Array.isArray(item.positions) ? item.positions : []
|
||||
for (const [positionIndex, position] of positions.entries()) {
|
||||
const items = detail.data.items
|
||||
for (const [itemIndex, original] of items.entries()) {
|
||||
// 每条真实命令前重新读取当前公共版本和可用来源,不从上次响应推算版本。
|
||||
detail = await pda(context, token, 'GET', '扫码前刷新公共版本及真实来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
let item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId))
|
||||
if (!success(detail.response) || !item) return false
|
||||
const sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position }))
|
||||
for (const [sourceIndex, source] of sources.entries()) {
|
||||
const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
|
||||
if (remaining <= 0) break
|
||||
const available = Number(position.availablePackages || 0)
|
||||
if (available <= 0) continue
|
||||
const available = source.carton ? 1 : Number(source.position.availablePackages)
|
||||
if (!Number.isSafeInteger(available) || available <= 0) continue
|
||||
const packages = Math.min(remaining, available)
|
||||
const body = {
|
||||
positionId: id(position.id), packages, productQuantity: packages,
|
||||
idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`),
|
||||
remark: `${context.marker}-phase5-dispatch-pick`,
|
||||
}
|
||||
const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`,
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body)
|
||||
const body = { recordVersion: version(detail.data.recordVersion),
|
||||
positionId: source.position ? id(source.position.positionId) : null,
|
||||
cartonMark: source.carton?.cartonMark || null, packages,
|
||||
productQuantity: source.carton ? Number(source.carton.productQuantity) : packages,
|
||||
idempotencyKey: key(context, 'DISPATCH-PICK', `${dispatchNo}-${itemIndex}-${sourceIndex}`),
|
||||
remark: `${context.marker}-phase5-dispatch-pick` }
|
||||
const path = `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`
|
||||
const picked = await pda(context, token, 'POST', `PDA真实扫码托盘${itemIndex + 1}来源${sourceIndex + 1}`, path, body)
|
||||
if (!success(picked.response)) return false
|
||||
const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`,
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body)
|
||||
context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response,
|
||||
success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages),
|
||||
trace(replay.response), OPTIONS)
|
||||
remaining -= packages
|
||||
const replay = await pda(context, token, 'POST', 'PDA扫码原命令重放', path, body)
|
||||
const stable = success(replay.response) && replay.data?.replayed === true
|
||||
&& String(replay.data.recordVersion) === String(picked.data.recordVersion)
|
||||
&& JSON.stringify(replay.data.result) === JSON.stringify(picked.data.result)
|
||||
&& JSON.stringify(replay.data.counts) === JSON.stringify(picked.data.counts)
|
||||
context.assert(MODULE, 'PDA扫码重放保留原结果及版本数量', replay.response, stable, trace(replay.response), OPTIONS)
|
||||
if (!stable) return false
|
||||
if (itemIndex === 0 && sourceIndex === 0) {
|
||||
const stale = await context.request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-STALE` }, token, {})
|
||||
context.addStep(MODULE, '旧公共版本扫码冲突且不得再扣库存', stale, conflict(stale), trace(stale), OPTIONS)
|
||||
if (!conflict(stale)) return false
|
||||
}
|
||||
detail = await pda(context, token, 'GET', '扫码后重新读取公共版本与数量', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId))
|
||||
if (!success(detail.response) || !item) return false
|
||||
}
|
||||
if (remaining !== 0) {
|
||||
blocker(context, `PDA托盘${itemIndex + 1}足量库存`,
|
||||
`scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response)
|
||||
if (Number(item.scheduledPackages) !== Number(item.pickedPackages)) {
|
||||
blocker(context, `PDA托盘${itemIndex + 1}足量库存`, '实际来源不足,保留本轮已扫证据', detail.response)
|
||||
return false
|
||||
}
|
||||
}
|
||||
const passed = detail.data?.stage === 'LOADING' && detail.data?.pickingReconciled === true
|
||||
&& Number(detail.data?.counts?.packageCount) > 0
|
||||
&& Number(detail.data.counts.packageCount) === Number(detail.data.counts.pickedPackageCount)
|
||||
context.assert(MODULE, '真实扫码核平仍停留LOADING等待显式装车完成', detail.response, passed, trace(detail.response), OPTIONS)
|
||||
return passed
|
||||
}
|
||||
|
||||
const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') }
|
||||
const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody)
|
||||
context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response,
|
||||
completed.data?.status === 'READY_TO_DEPART'
|
||||
&& Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages)
|
||||
&& Number(completed.data?.remainingPackages) === 0,
|
||||
trace(completed.response), OPTIONS)
|
||||
if (!success(completed.response)) return false
|
||||
const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody)
|
||||
context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response,
|
||||
replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS)
|
||||
return success(replay.response)
|
||||
export async function arrangeDispatchDelivery(context, dispatchNo) {
|
||||
const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`)
|
||||
const value = preview.data
|
||||
if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE'
|
||||
|| value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true
|
||||
|| !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash)
|
||||
|| !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey)
|
||||
|| !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key)
|
||||
|| !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) {
|
||||
blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response)
|
||||
return null
|
||||
}
|
||||
const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo),
|
||||
cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash,
|
||||
groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash,
|
||||
plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64),
|
||||
carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
|
||||
vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null,
|
||||
remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash,
|
||||
feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [],
|
||||
fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service,
|
||||
direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) }
|
||||
const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body)
|
||||
if (!success(transferred.response)) return null
|
||||
const result = transferred.data?.result
|
||||
const deliveries = transferDeliveries(transferred.data)
|
||||
const outbounds = result?.outbounds
|
||||
const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort()
|
||||
const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort()
|
||||
const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE'
|
||||
&& transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo
|
||||
&& result.cargoHash === body.cargoHash && deliveries.length === value.groups.length
|
||||
&& new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length
|
||||
&& JSON.stringify(customerGroups) === JSON.stringify(actualGroups)
|
||||
&& Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size
|
||||
&& outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND'
|
||||
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId))
|
||||
&& deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount))
|
||||
&& deliveries.every(row => row.status === 'WAIT_DELIVERY')
|
||||
context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS)
|
||||
if (!passed) return null
|
||||
const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body)
|
||||
const stable = success(replay.response) && replay.data?.replayed === true
|
||||
&& String(replay.data.recordVersion) === String(transferred.data.recordVersion)
|
||||
&& JSON.stringify(replay.data.result) === JSON.stringify(result)
|
||||
context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS)
|
||||
if (!stable) return null
|
||||
return { ...transferred, preview: value, outbounds, deliveries }
|
||||
}
|
||||
|
||||
/** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */
|
||||
@@ -947,120 +1090,23 @@ export async function run(context) {
|
||||
const merchantToken = await loginMerchant(context, customerId)
|
||||
if (!merchantToken) return
|
||||
|
||||
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
context = await fulfillmentEvidenceContext(context)
|
||||
const beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
|
||||
const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = {
|
||||
frozenPlanPalletIds:source.dispatch.planPalletIds,
|
||||
frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes,
|
||||
beforeEdit:beforeEdit.data,
|
||||
}
|
||||
context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response,
|
||||
dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS)
|
||||
sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes,
|
||||
beforeEdit: beforeEdit.data }
|
||||
context.assert(MODULE, '排车来源和目的地与本轮生产者守恒', beforeEdit.response,
|
||||
dispatchDestinationConservation(beforeEdit.data,source.dispatch), trace(beforeEdit.response), OPTIONS)
|
||||
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return
|
||||
const updateBody = {
|
||||
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
|
||||
driverName: `五期验收司机-${context.marker.slice(-6)}`,
|
||||
driverPhone: '19900000000', carrierName: '五期开发验收车队',
|
||||
estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
}
|
||||
const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody)
|
||||
context.assert(MODULE, '排车编辑字段完整回显', updated.response,
|
||||
updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate
|
||||
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route,
|
||||
trace(updated.response), OPTIONS)
|
||||
if (!success(updated.response)) return
|
||||
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId)
|
||||
const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`)
|
||||
context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol,
|
||||
bol.http === 200 && /application\/pdf/i.test(bol.contentType || '')
|
||||
&& new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS)
|
||||
|
||||
const transferBody = {
|
||||
idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(),
|
||||
deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64),
|
||||
carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
|
||||
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName,
|
||||
driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate,
|
||||
appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`,
|
||||
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',
|
||||
currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }],
|
||||
}
|
||||
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody)
|
||||
const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
const outboundNo = id(transferred.data?.outboundNo)
|
||||
const deliveries = transferDeliveries(transferred.data)
|
||||
const deliveryNos = deliveryNumbers(transferred.data)
|
||||
const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId))
|
||||
routeProof.deliveries = deliveries
|
||||
context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response,
|
||||
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS)
|
||||
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
|
||||
const deliveryNo = deliveryNos[0] || ''
|
||||
context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response,
|
||||
nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length
|
||||
&& new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank)
|
||||
&& transferred.data?.outboundStatus === 'WAIT_OUTBOUND'
|
||||
&& deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY')
|
||||
&& deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount)
|
||||
&& Number(transferred.data?.packageCount) > 0,
|
||||
`outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS)
|
||||
if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos })
|
||||
const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单',
|
||||
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody)
|
||||
context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response,
|
||||
adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo
|
||||
&& JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS)
|
||||
const lockedItem = updated.data?.items?.[0]
|
||||
if (lockedItem?.id) {
|
||||
const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`)
|
||||
context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS)
|
||||
const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response,
|
||||
Number(stable.data?.packageCount) === Number(updated.data?.packageCount)
|
||||
&& JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS)
|
||||
} else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID')
|
||||
const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放',
|
||||
query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody)
|
||||
context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response,
|
||||
transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo
|
||||
&& JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS)
|
||||
const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {})
|
||||
context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict,
|
||||
success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo
|
||||
&& transferConflict.body?.data?.replayed === true
|
||||
&& JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS)
|
||||
|
||||
const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单',
|
||||
query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo }))
|
||||
const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : []
|
||||
context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response,
|
||||
deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number
|
||||
&& id(value?.dispatchNo) === dispatchNo)),
|
||||
`records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS)
|
||||
const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情',
|
||||
`${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`)
|
||||
context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response,
|
||||
id(merchantDetail.data?.order?.deliveryNo) === deliveryNo
|
||||
&& id(merchantDetail.data?.order?.dispatchNo) === dispatchNo
|
||||
&& nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode
|
||||
|| merchantDetail.data?.order?.destinationWarehouseId))
|
||||
&& Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0
|
||||
&& Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations),
|
||||
`skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`,
|
||||
OPTIONS)
|
||||
|
||||
const access = await resolvePickerAccess(context)
|
||||
const picker = access ? await createPicker(context, 5, warehouseId, access) : null
|
||||
const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))]
|
||||
let tripProof
|
||||
if (dispatchInboundCodes.length === 0) {
|
||||
blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码')
|
||||
await disablePicker(context, picker, 5)
|
||||
return
|
||||
}
|
||||
let deliveries, outbounds, transferred
|
||||
try {
|
||||
if (!nonBlank(picker?.token)) {
|
||||
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
|
||||
@@ -1068,90 +1114,117 @@ export async function run(context) {
|
||||
}
|
||||
const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
|
||||
const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId)
|
||||
context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response,
|
||||
success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS)
|
||||
if (!sourceIds) return
|
||||
const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds
|
||||
context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances }
|
||||
const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds)
|
||||
const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo)
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } })
|
||||
const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances)
|
||||
context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS)
|
||||
if (!sourcesBeforeValid || !ledgerBeforePick) return
|
||||
tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId)
|
||||
const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo)
|
||||
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds)
|
||||
const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo)
|
||||
const expectedPackages = Number(transferred.data.packageCount)
|
||||
const conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages,
|
||||
sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick,
|
||||
ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick })
|
||||
const shippedRows = conservation.newLedger
|
||||
if (!pickedSuccessfully) return
|
||||
const tripVersion = await confirmFleetLoading(context, MODULE, tripProof)
|
||||
if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return
|
||||
const customerIds = [...new Set(sourceTask.data.items.map(item => id(item.customerId)))]
|
||||
const detailCustomers = [...new Set(beforeEdit.data.orderDetails.map(row => id(row.customerId)))].sort()
|
||||
if (!customerIds.every(nonBlank) || JSON.stringify([...customerIds].sort()) !== JSON.stringify(detailCustomers)) {
|
||||
blocker(context, '整车真实客户集合', 'PDA客户与本轮原生详情不一致'); return
|
||||
}
|
||||
const proofs = context.createdResources.phase5OverseasFulfillment.inventoryByCustomer = []
|
||||
for (const currentCustomer of customerIds) {
|
||||
const task = { ...sourceTask.data, items: sourceTask.data.items.filter(item => id(item.customerId) === currentCustomer) }
|
||||
const sources = dispatchInventorySources(task, dispatchNo, warehouseId)
|
||||
const inboundCodes = [...new Set(beforeEdit.data.orderDetails.filter(row => id(row.customerId) === currentCustomer)
|
||||
.map(row => row.inboundCode).filter(nonBlank))]
|
||||
if (!sources || !inboundCodes.length) { blocker(context, '整车客户库存生产证据', '缺少当前客户真实库存位置或入仓码'); return }
|
||||
const stockBefore = await inventorySnapshot(context, currentCustomer, warehouseId, inboundCodes, `客户${currentCustomer}扫码前库存`, sources.sourceBalanceIds)
|
||||
const ledgerBefore = await shipmentLedger(context, currentCustomer, dispatchNo, `客户${currentCustomer}扫码前SHIP`, 'SHIP', dispatchNo)
|
||||
if (!stockBefore || !ledgerBefore || !inventorySourcesPresent(stockBefore, sources.sourcePositionBalances)) return
|
||||
proofs.push({ customerId: currentCustomer, inboundCodes, ...sources, stockBefore, ledgerBefore,
|
||||
expectedPackages: task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) })
|
||||
}
|
||||
await context.checkpoint()
|
||||
if (!await pickAll(context, picker.token, dispatchNo)) return
|
||||
for (const proof of proofs) {
|
||||
proof.stockAfter = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes,
|
||||
`客户${proof.customerId}扫码后库存`, proof.sourceBalanceIds)
|
||||
proof.ledgerAfter = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}扫码后SHIP`, 'SHIP', dispatchNo)
|
||||
const conservation = dispatchStockConservation(context, { ...proof, warehouseId, dispatchNo })
|
||||
proof.conservation = conservation.proof
|
||||
if (!conservation.passed) return
|
||||
}
|
||||
const primaryInventory = proofs.find(proof => proof.customerId === customerId)
|
||||
if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation }
|
||||
await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements)
|
||||
transferred = await arrangeDispatchDelivery(context, dispatchNo)
|
||||
if (!transferred) return
|
||||
deliveries = transferred.deliveries; outbounds = transferred.outbounds
|
||||
routeProof.deliveries = deliveries
|
||||
context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response,
|
||||
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS)
|
||||
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
|
||||
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
|
||||
if (!ready) return
|
||||
const departBody = { recordVersion: tripVersion, idempotencyKey: key(context, 'DISPATCH-DEPART').slice(0, 128), remark: `${context.marker}-depart` }
|
||||
const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库',
|
||||
const departBody = { recordVersion: version(ready.data.recordVersion),
|
||||
idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` }
|
||||
const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
||||
context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response,
|
||||
departed.data?.status === 'IN_TRANSIT' && departed.data?.tripNo === tripProof.tripNo
|
||||
&& departed.data?.dispatches?.some(item => item.dispatchNo === dispatchNo) && Number(departed.data?.shippedPackages) > 0,
|
||||
trace(departed.response), OPTIONS)
|
||||
if (!success(departed.response)) return
|
||||
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放',
|
||||
const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0)
|
||||
const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo
|
||||
&& departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING'
|
||||
&& Number(departed.data.result?.shippedPackages) === expectedPackages
|
||||
&& JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort())
|
||||
context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS)
|
||||
if (!departedCorrectly) return
|
||||
const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放',
|
||||
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
|
||||
context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response,
|
||||
departReplay.data?.replayed === true
|
||||
&& Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages),
|
||||
trace(departReplay.response), OPTIONS)
|
||||
const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds)
|
||||
const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo)
|
||||
const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof
|
||||
inventoryProof.snapshots.afterDepart = stockAfterDepart
|
||||
inventoryProof.ledgers.afterDepart = ledgerAfterDepart
|
||||
context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response,
|
||||
inventorySourcesPresent(stockAfterDepart, sourcePositionBalances)
|
||||
&& sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS)
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, {
|
||||
sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore,
|
||||
afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages,
|
||||
shipmentLedgerIds: (shippedRows || []).map(row => row.id),
|
||||
canDepart: ready.data?.canDepart === true,
|
||||
})
|
||||
const replayStable = success(departReplay.response) && departReplay.data?.replayed === true
|
||||
&& String(departReplay.data.recordVersion) === String(departed.data.recordVersion)
|
||||
&& JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result)
|
||||
context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS)
|
||||
if (!replayStable) return
|
||||
for (const proof of proofs) {
|
||||
proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes,
|
||||
`客户${proof.customerId}发车后库存`, proof.sourceBalanceIds)
|
||||
proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo)
|
||||
const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances)
|
||||
&& sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)
|
||||
context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS)
|
||||
if (!unchanged) return
|
||||
}
|
||||
// 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。
|
||||
await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, picker.token, warehouseId)
|
||||
} finally {
|
||||
await disablePicker(context, picker, 5)
|
||||
if (picker) picker.token = null
|
||||
try { await disablePicker(context, picker, 5) }
|
||||
finally { if (picker) picker.token = null; await context.checkpoint() }
|
||||
}
|
||||
const primaryDelivery = deliveries.find(row => id(row.customerId) === customerId)
|
||||
if (!primaryDelivery) { blocker(context, '客户签收资料前置', '本轮主客户不在实际派送结果中'); return }
|
||||
const deliveryNo = primaryDelivery.deliveryNo, outboundNo = primaryDelivery.outboundNo
|
||||
const deliveryNos = deliveries.map(row => row.deliveryNo).sort()
|
||||
Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos, outbounds, deliveries })
|
||||
for (const outboundRef of outbounds) {
|
||||
const outbound = await platform(context, 'GET', '逐客户回查发车后真实出库状态', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundRef.outboundNo)}`)
|
||||
const passed = outbound.data?.outbound?.outboundNo === outboundRef.outboundNo
|
||||
&& id(outbound.data?.outbound?.customerId) === id(outboundRef.customerId) && outbound.data?.outbound?.status === 'OUTBOUNDED'
|
||||
context.assert(MODULE, '每客户出库单独立OUTBOUNDED且归属不漂移', outbound.response, passed, trace(outbound.response), OPTIONS)
|
||||
if (!passed) return
|
||||
}
|
||||
const customerDeliveries = deliveries.filter(row => id(row.customerId) === customerId)
|
||||
const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', query(MERCHANT_DELIVERY_BASE, { page: 1, size: 200, dispatchNo }))
|
||||
const merchantRows = merchantPage.data?.records
|
||||
const merchantScoped = Array.isArray(merchantRows)
|
||||
&& customerDeliveries.every(delivery => merchantRows.some(row => row.deliveryNo === delivery.deliveryNo))
|
||||
&& merchantRows.every(row => id(row.customerId) === customerId)
|
||||
context.assert(MODULE, '客户只看本车自己的派送单', merchantPage.response, merchantScoped, trace(merchantPage.response), OPTIONS)
|
||||
if (!merchantScoped) return
|
||||
const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`)
|
||||
context.assert(MODULE, '客户派送详情保留订单货物费用操作真实结构', merchantDetail.response,
|
||||
merchantDetail.data?.order?.deliveryNo === deliveryNo && Array.isArray(merchantDetail.data?.skus)
|
||||
&& Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), trace(merchantDetail.response), OPTIONS)
|
||||
|
||||
const outbound = await platform(context, 'GET', '回查发车后出库单状态',
|
||||
query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 }))
|
||||
context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response,
|
||||
outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED',
|
||||
trace(outbound.response), OPTIONS)
|
||||
|
||||
for (const [index, delivery] of deliveries.entries()) {
|
||||
const currentNo = id(delivery.deliveryNo)
|
||||
const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`,
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId }))
|
||||
const deliveryVersion = Number(beforeStatus.data?.order?.version)
|
||||
context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response,
|
||||
Number.isInteger(deliveryVersion) && deliveryVersion >= 0,
|
||||
`version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS)
|
||||
if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return
|
||||
const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`,
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }),
|
||||
{ status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` })
|
||||
context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response,
|
||||
delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS)
|
||||
if (!success(delivering.response)) return
|
||||
for (const delivery of deliveries) {
|
||||
const current = await platform(context, 'GET', '逐客户核对发车后真实派送状态',
|
||||
query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, { customerId: id(delivery.customerId) }))
|
||||
const passed = current.data?.order?.status === 'DELIVERING'
|
||||
&& id(current.data.order.customerId) === id(delivery.customerId) && current.data.order.outboundNo === delivery.outboundNo
|
||||
context.assert(MODULE, '派送由实际发车进入DELIVERING并保留客户出库关联', current.response, passed, trace(current.response), OPTIONS)
|
||||
if (!passed) return
|
||||
}
|
||||
|
||||
const completedDeliveries = []
|
||||
for (const [index, delivery] of deliveries.entries()) {
|
||||
const completed = await saveReceiptAndComplete(context, delivery, customerId, index)
|
||||
const completed = await saveReceiptAndComplete(context, delivery, id(delivery.customerId), index)
|
||||
if (!completed) return
|
||||
completedDeliveries.push(completed)
|
||||
}
|
||||
@@ -1160,6 +1233,12 @@ export async function run(context) {
|
||||
&& completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'),
|
||||
`deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS)
|
||||
|
||||
const normalCompletion = await poll(() => context.request('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`),
|
||||
response => success(response) && response.body?.data?.status === 'ACTIVE' && response.body?.data?.stage === 'DELIVERED')
|
||||
context.assert(MODULE, '全部客户正常签收汇总整车DELIVERED', normalCompletion.result,
|
||||
success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS)
|
||||
if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return
|
||||
context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data
|
||||
const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` })
|
||||
context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete,
|
||||
removedComplete?.http === 404 || removedComplete?.http === 405
|
||||
@@ -1201,11 +1280,12 @@ export async function run(context) {
|
||||
return
|
||||
}
|
||||
context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId
|
||||
await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken })
|
||||
const rootCompletion = await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken })
|
||||
if (!rootCompletion?.passed) return
|
||||
const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken
|
||||
|| await createSignedDocumentIsolationCustomer(context)
|
||||
await verifyCustomerSignedDocuments(context, merchantToken,
|
||||
context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken)
|
||||
if (!await verifyCustomerSignedDocuments(context, merchantToken,
|
||||
context.createdResources.phase5OverseasFulfillment.receiptFiles?.filter(receipt => receipt.customerId === customerId), foreignMerchantToken)) return
|
||||
|
||||
const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果',
|
||||
query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), {
|
||||
@@ -1216,6 +1296,19 @@ export async function run(context) {
|
||||
savedVisit.data?.visit?.status === 'COMPLETED'
|
||||
&& savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode
|
||||
&& savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS)
|
||||
if (!success(savedVisit.response) || savedVisit.data?.visit?.status !== 'COMPLETED'
|
||||
|| savedVisit.data.visit.selectedInboundCode !== inbound.inboundCode
|
||||
|| savedVisit.data.visit.visitResult !== 'SATISFIED') return
|
||||
|
||||
// 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。
|
||||
if (context.context?.phase5TailVerification?.runReturnScenario !== true) {
|
||||
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
|
||||
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'
|
||||
await context.checkpoint()
|
||||
return
|
||||
}
|
||||
|
||||
const manualExceptionBody = {
|
||||
customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo,
|
||||
@@ -1223,10 +1316,6 @@ export async function run(context) {
|
||||
}
|
||||
let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常',
|
||||
`${LASTLEG_BASE}/exception-orders`, manualExceptionBody)
|
||||
if (manualException.response.http === 0) {
|
||||
manualException = await platform(context, 'POST', '人工异常连接失败后使用同载荷重试',
|
||||
`${LASTLEG_BASE}/exception-orders`, manualExceptionBody)
|
||||
}
|
||||
const manualExceptionNo = id(manualException.data?.exceptionNo)
|
||||
context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response,
|
||||
nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS)
|
||||
@@ -1264,7 +1353,7 @@ export async function run(context) {
|
||||
context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt
|
||||
context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo],
|
||||
businesses: deliveryNos.map(deliveryNo => ({ customerId, deliveryNo })), receiptsVisible: true })
|
||||
businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true })
|
||||
context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt
|
||||
? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT'
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
|
||||
completionCounts, exactCompletionOutboxProof, rootCompletionTimeMatches, resolveF40MerchantCredentials,
|
||||
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder,
|
||||
f40CreateDispatchBody, resolveF40LoadingMeasurements, sendFragment, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder,
|
||||
validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder,
|
||||
verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence,
|
||||
resolveF40ReceivingWarehouse, run } from './phase5-root-fulfillment.mjs'
|
||||
@@ -153,24 +153,52 @@ test('F40 retains partial and failed receiving raw snapshots without exporting i
|
||||
assert.match(source,/proof.receiptReplayProofs \|\|= \[\]/)
|
||||
})
|
||||
|
||||
test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => {
|
||||
const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8')
|
||||
const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'}
|
||||
const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now)
|
||||
for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) {
|
||||
assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field])
|
||||
}
|
||||
assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03')
|
||||
assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03')
|
||||
assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP')
|
||||
assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托')
|
||||
for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field])
|
||||
assert.equal(body.fees.length,1)
|
||||
assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])),
|
||||
{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10})
|
||||
assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/)
|
||||
assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/)
|
||||
function nativeF40Candidate() {
|
||||
const sourceVersion = 'a'.repeat(64)
|
||||
return { sourceKey: 'PLAN:9007199254740993', sourceVersion, customerId: '90', warehouseId: '80',
|
||||
inboundCode: 'IN-F40', availablePackages: '1', destination: { key: 'FBA:ONT8', type: 'FBA', warehouseCode: 'ONT8' },
|
||||
selection: { sourceType: 'INBOUND_PLAN', planPalletId: '9007199254740993', warehousePalletId: '9007199254740994',
|
||||
returnReceiptId: null, relabelNo: null, sourceVersion }, blockReasons: [], appointmentBatches: [{
|
||||
batchKey: 'FBA_APPOINTMENT:9007199254740995', customerId: '90', recordVersion: '9007199254740996',
|
||||
sources: [{ sourceKey: 'ORDER:9007199254740997', destinationKey: 'FBA:ONT8', packageCount: 1 }] }] }
|
||||
}
|
||||
|
||||
test('F40 native Create uses frozen candidate source and exact FBA batch version including zero, without legacy vehicle/Transfer fields', () => {
|
||||
const candidate = nativeF40Candidate()
|
||||
const body = f40CreateDispatchBody('offline', '80', '90', [candidate])
|
||||
assert.equal(body.warehouseId, '80')
|
||||
assert.equal(body.cargo[0].planPalletId, '9007199254740993')
|
||||
assert.equal(body.cargo[0].warehousePalletId, '9007199254740994')
|
||||
assert.equal(body.cargo[0].sourceVersion, candidate.sourceVersion)
|
||||
assert.deepEqual(body.batchAllocations, [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9007199254740995',
|
||||
customerId: '90', sourceKey: 'ORDER:9007199254740997', destinationKey: 'FBA:ONT8', packageCount: 1, recordVersion: '9007199254740996' }])
|
||||
candidate.appointmentBatches[0].recordVersion = '0'
|
||||
assert.equal(f40CreateDispatchBody('offline', '80', '90', [candidate]).batchAllocations[0].recordVersion, '0')
|
||||
assert.equal(body.vehicle, null); assert.equal(body.driver, null)
|
||||
assert.equal('vehiclePlate' in body, false); assert.equal('plannedDeliveryAt' in body, false)
|
||||
assert.equal(f40LocalDateTime(new Date('2026-09-30T01:02:03.456Z')), '2026-09-30T01:02:03')
|
||||
const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java', import.meta.url), 'utf8')
|
||||
assert.match(dto, /record Create/)
|
||||
for (const field of ['warehouseId', 'cargo', 'batchAllocations', 'sourceVersion']) assert.ok(dto.includes(field), field)
|
||||
})
|
||||
|
||||
test('F40 native Create rejects foreign/ambiguous sources and incomplete batch allocation instead of guessing source or version', () => {
|
||||
for (const mutate of [value => { value.customerId = '91' }, value => { value.warehouseId = '81' },
|
||||
value => { value.sourceVersion = 'stale' }, value => { value.selection.sourceVersion = 'stale' },
|
||||
value => { value.selection.planPalletId = 9007199254740992 }, value => { value.availablePackages = '9007199254740993' },
|
||||
value => { value.blockReasons = ['NOT_RECEIVED'] }, value => { value.appointmentBatches[0].recordVersion = null },
|
||||
value => { value.appointmentBatches[0].recordVersion = 9007199254740992 },
|
||||
value => { value.appointmentBatches[0].customerId = '91' },
|
||||
value => { value.appointmentBatches[0].sources[0].packageCount = 2 },
|
||||
value => { value.appointmentBatches[0].sources[0].destinationKey = 'FBA:OTHER' },
|
||||
value => { value.appointmentBatches.push(structuredClone(value.appointmentBatches[0])) }]) {
|
||||
const candidate = nativeF40Candidate(); mutate(candidate)
|
||||
assert.throws(() => f40CreateDispatchBody('offline', '80', '90', [candidate]))
|
||||
}
|
||||
const candidate = nativeF40Candidate()
|
||||
assert.throws(() => f40CreateDispatchBody('offline', '80', '90', [candidate, candidate]))
|
||||
})
|
||||
|
||||
test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => {
|
||||
const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'}
|
||||
const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'}
|
||||
@@ -537,3 +565,130 @@ test('early F40 failures become FAILED with only a safe stage and preserve the o
|
||||
assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED')
|
||||
assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false)
|
||||
})
|
||||
|
||||
|
||||
function nativeFragmentFixture() {
|
||||
const context = fixture(), events = [], calls = []
|
||||
const producer = { tenantId: '90', inboundCode: 'IN-F40', sourceInboundId: '901', sourceOrderId: '100',
|
||||
rootOrderId: '100', containerBookingId: '902' }
|
||||
const rootProof = { fragments: [producer], currentStage: 'DISPATCH_AND_SIGNATURE_1' }
|
||||
context.context = { f40BaseMarker: 'offline-F40', f40RootFulfillmentProof: rootProof }
|
||||
const measurement = { environment: 'TEST', marker: 'offline-F40-SEND-0', dispatchNo: 'DS-F40', pallets: [] }
|
||||
context.context.f40LoadingMeasurements = { 'DS-F40': measurement }
|
||||
context.checkpoint = async () => { events.push('checkpoint') }
|
||||
const delivery = { customerId: '90', deliveryNo: 'DL-F40', outboundNo: 'OB-F40', packageCount: '1',
|
||||
destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } }
|
||||
let created, departed, signed = false
|
||||
context.request = async (method, path, body, token) => {
|
||||
calls.push({ method, path, body: structuredClone(body), token })
|
||||
let data
|
||||
if (method === 'GET' && path.includes('/candidates?')) {
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('warehouseId'), '80')
|
||||
assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('sourceStage'), false)
|
||||
data = { complete: true, candidates: [nativeF40Candidate()] }
|
||||
} else if (method === 'POST' && path.endsWith('/dispatches')) {
|
||||
events.push('create')
|
||||
if (created) data = { ...created, replayed: true }
|
||||
else data = created = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0',
|
||||
result: { dispatchNo: 'DS-F40', changedItemIds: ['9007199254740998'] } }
|
||||
} else if (method === 'POST' && path.endsWith('/depart')) {
|
||||
events.push('depart')
|
||||
assert.equal(token, 'pda-memory')
|
||||
assert.equal(body.recordVersion, '9007199254740999')
|
||||
if (departed) {
|
||||
assert.deepEqual(body, calls.find(call => call.path.endsWith('/depart')).body)
|
||||
data = { ...departed, replayed: true }
|
||||
} else data = departed = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254741000',
|
||||
result: { shippedPackages: '1', deliveries: [delivery], outbounds: [{ customerId: '90', outboundNo: 'OB-F40' }] } }
|
||||
} else if (method === 'GET' && path.includes('/delivery-orders/')) {
|
||||
data = { order: { id: '9007199254741001', customerId: '90', outboundNo: 'OB-F40', deliveryNo: 'DL-F40',
|
||||
version: '9007199254741002', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY' }, skus: [{ inboundCode: 'IN-F40', packageCount: 1 }] }
|
||||
} else if (method === 'GET' && path.endsWith('/outbound-orders/OB-F40')) {
|
||||
data = { outbound: { customerId: '90', outboundNo: 'OB-F40', status: 'OUTBOUNDED' }, pallets: [{ inboundCode: 'IN-F40' }] }
|
||||
} else if (method === 'GET' && path.endsWith('/dispatches/DS-F40')) {
|
||||
assert.equal(signed, true); events.push('normal-completed')
|
||||
data = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERED', recordVersion: '9007199254741004' }
|
||||
} else throw new Error(`Unexpected offline wire ${method} ${path}`)
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
}
|
||||
const deps = {
|
||||
async fulfillmentEvidenceContext(current) { events.push('evidence'); return current },
|
||||
async prepareFleetTrip(current, module, dispatchNo, warehouseId) {
|
||||
events.push('prepare-review'); assert.equal(dispatchNo, 'DS-F40'); assert.equal(warehouseId, '80')
|
||||
return { dispatchNo, warehouseId, tripNo: 'TR-F40', recordVersion: '1' }
|
||||
},
|
||||
async pickAll(current, token, dispatchNo) { events.push('pick'); assert.equal(token, 'pda-memory'); assert.equal(dispatchNo, 'DS-F40'); return true },
|
||||
async confirmFleetLoading(current, module, proof, token, actual) {
|
||||
events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement)
|
||||
if (!actual) throw new Error('实际测量缺少有效palletSpaces')
|
||||
return '9007199254740998'
|
||||
},
|
||||
async arrangeDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery],
|
||||
outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { result: { deliveries: [delivery] } } } },
|
||||
async verifyDepartureReadiness() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999' } } },
|
||||
async saveReceiptAndComplete(current, actual, customerId, index) {
|
||||
events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0)
|
||||
signed = true
|
||||
return { order: { customerId: '90', deliveryNo: 'DL-F40', status: 'DELIVERED', version: '9007199254741003', signedAt: '2026-10-19T01:00:00' } }
|
||||
},
|
||||
async finishFleetTrip() { events.push('finish'); return { status: 'VERIFIED' } },
|
||||
}
|
||||
return { context, producer, rootProof, deps, events, calls, measurement }
|
||||
}
|
||||
|
||||
test('F40 fragment uses native creation, reviews, real PDA scan, explicit loading/token/evidence, arrangement, refreshed departure version and normal signature', async () => {
|
||||
const f = nativeFragmentFixture()
|
||||
const result = await sendFragment(f.context, f.producer, { warehouseId: '80', token: 'pda-memory' }, 'merchant-memory', 0, f.deps)
|
||||
assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003')
|
||||
assert.equal(result.signedAt, '2026-10-19T01:00:00')
|
||||
assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick',
|
||||
'loading-complete', 'arrange', 'read-current-depart-version', 'depart', 'depart', 'receipt', 'normal-completed', 'finish'])
|
||||
assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false)
|
||||
assert.equal(f.calls.some(call => call.method === 'PUT'), false)
|
||||
assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED')
|
||||
assert.equal(f.rootProof.dispatches[0].departureReplay.replayed, true)
|
||||
assert.equal(f.context.steps.every(step => step.passed), true)
|
||||
})
|
||||
|
||||
test('F40 missing real measurement evidence stops before arranging, departing or signing while keeping the failed source proof', async () => {
|
||||
const f = nativeFragmentFixture()
|
||||
delete f.context.context.f40LoadingMeasurements
|
||||
f.deps.confirmFleetLoading = async (_context, _module, _proof, token, actual) => {
|
||||
assert.equal(token, 'pda-memory'); assert.equal(actual, undefined)
|
||||
throw new Error('实际测量缺少有效palletSpaces')
|
||||
}
|
||||
await assert.rejects(sendFragment(f.context, f.producer, { warehouseId: '80', token: 'pda-memory' }, 'merchant-memory', 0, f.deps), /实际测量缺少/)
|
||||
assert.equal(f.events.includes('arrange'), false)
|
||||
assert.equal(f.calls.some(call => call.path.endsWith('/depart')), false)
|
||||
assert.equal(f.events.includes('receipt'), false)
|
||||
assert.equal(f.rootProof.dispatches[0].dispatchNo, 'DS-F40')
|
||||
assert.equal(f.rootProof.dispatches[0].stage, 'FAILED')
|
||||
assert.equal(f.rootProof.dispatches[0].failedAction, 'LOADING_COMPLETE')
|
||||
assert.equal(f.events.at(-1), 'checkpoint')
|
||||
})
|
||||
|
||||
test('F40 measurement reader receives the exact current TEST source scope and preserves its returned evidence without inferred values', async () => {
|
||||
const f = nativeFragmentFixture(), response = { arbitrary: 'opaque-original-proof' }
|
||||
f.context.marker = 'offline-F40-SEND-0'
|
||||
f.context.context.f40LoadingMeasurements = async scope => {
|
||||
assert.deepEqual(scope, { environment: 'TEST', marker: 'offline-F40-SEND-0', dispatchNo: 'DS-F40',
|
||||
warehouseId: '80', sourceInboundId: '901', inboundCode: 'IN-F40' })
|
||||
return response
|
||||
}
|
||||
assert.equal(await resolveF40LoadingMeasurements(f.context, 'DS-F40', f.producer, '80'), response)
|
||||
})
|
||||
|
||||
test('F40 fresh receipt revision evidence preserves string versions above safe integer precision', () => {
|
||||
const oldVersion = '9007199254740993', newVersion = '9007199254740994'
|
||||
const documents = { version: oldVersion, uploadedCategoryCount: 1, categories: [{ category: 'RECEIPT', files: [{ fileId: '9007199254740995' }] }], replayed: false }
|
||||
const before = { documents, delivery: { order: { version: oldVersion, status: 'DELIVERED', signedAt: '2026-10-19T01:00:00' },
|
||||
operations: [{ id: '9007199254740996', operationNode: 'RECEIPT_AUTO_COMPLETE' }] }, revisions: [{ version: oldVersion }] }
|
||||
const after = structuredClone(before)
|
||||
after.documents.version = newVersion; after.delivery.order.version = newVersion
|
||||
after.delivery.operations.push({ id: '9007199254740997', operationNode: 'SAVE_DOCUMENTS', result: '保存派送资料:1/3类',
|
||||
operatedAt: '2026-10-19T01:01:00', operatorName: 'TEST' })
|
||||
after.revisions.push({ version: newVersion, savedAt: '2026-10-19T01:01:00', operatorId: 'platform', snapshot: after.documents })
|
||||
assert.equal(validFreshReceiptSave(before, after), true)
|
||||
after.documents.version = '9007199254740995'
|
||||
assert.equal(validFreshReceiptSave(before, after), false)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
|
||||
import { stableLoadedCartonMarks } from './phase3-loading.mjs'
|
||||
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
|
||||
resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs'
|
||||
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs'
|
||||
import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs'
|
||||
import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs'
|
||||
@@ -15,7 +15,17 @@ const MODULE = 'phase5-root-fulfillment'
|
||||
const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true }
|
||||
const DISPATCH = '/api/platform/v1/last-leg/dispatches'
|
||||
const PDA = '/api/pda/v1/last-leg/dispatches'
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const id = value => {
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('F40业务ID精度无效')
|
||||
return value == null ? '' : String(value)
|
||||
}
|
||||
const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value))
|
||||
&& /^(0|[1-9]\d*)$/.test(String(value))
|
||||
const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null
|
||||
const wireVersion = value => {
|
||||
if (!validVersion(value)) throw new Error('F40服务器版本缺失或精度无效')
|
||||
return value
|
||||
}
|
||||
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
|
||||
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
|
||||
|
||||
@@ -66,11 +76,12 @@ export function preserveReceivingEvidence(proof, scope) {
|
||||
proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || []
|
||||
}
|
||||
export function validFreshReceiptSave(before, after) {
|
||||
const previousVersion = Number(before?.documents?.version)
|
||||
const nextVersion = Number(after?.documents?.version)
|
||||
if (!Number.isSafeInteger(previousVersion) || nextVersion !== previousVersion + 1
|
||||
|| Number(before?.delivery?.order?.version) !== previousVersion
|
||||
|| Number(after?.delivery?.order?.version) !== nextVersion
|
||||
if (![before?.documents?.version, after?.documents?.version, before?.delivery?.order?.version, after?.delivery?.order?.version].every(validVersion)) return false
|
||||
const previousVersion = BigInt(before.documents.version)
|
||||
const nextVersion = BigInt(after.documents.version)
|
||||
if (nextVersion !== previousVersion + 1n
|
||||
|| BigInt(before.delivery.order.version) !== previousVersion
|
||||
|| BigInt(after.delivery.order.version) !== nextVersion
|
||||
|| before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false
|
||||
const same = (left,right) => JSON.stringify(left) === JSON.stringify(right)
|
||||
if (!same(before.documents,{...after.documents,version:before.documents.version})) return false
|
||||
@@ -87,9 +98,9 @@ export function validFreshReceiptSave(before, after) {
|
||||
operations:oldOperations})) return false
|
||||
if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions)
|
||||
|| after.revisions.length !== before.revisions.length + 1) return false
|
||||
const addedRevisions = after.revisions.filter(row => Number(row.version) === nextVersion)
|
||||
const addedRevisions = after.revisions.filter(row => validVersion(row.version) && BigInt(row.version) === nextVersion)
|
||||
return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId)
|
||||
&& same(before.revisions,after.revisions.filter(row => Number(row.version) !== nextVersion))
|
||||
&& same(before.revisions,after.revisions.filter(row => !validVersion(row.version) || BigInt(row.version) !== nextVersion))
|
||||
&& same(addedRevisions[0].snapshot,after.documents)
|
||||
}
|
||||
export function resolveF40MerchantCredentials(context) {
|
||||
@@ -102,18 +113,48 @@ export function resolveF40MerchantCredentials(context) {
|
||||
if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null
|
||||
return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) }
|
||||
}
|
||||
export function f40TransferBody(marker, index, vehicle, now = new Date()) {
|
||||
return {
|
||||
idempotencyKey:`${marker}-F40-TRANSFER-${index}`,
|
||||
plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)),
|
||||
deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64),
|
||||
carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET',
|
||||
vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName,
|
||||
driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate,
|
||||
appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`,
|
||||
fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10,
|
||||
remark:`${marker}-acceptance`}],
|
||||
}
|
||||
/** 本片只接受正式候选的冻结选择器和FBA逐票来源,不补猜来源版本。 */
|
||||
export function f40CreateDispatchBody(marker, warehouseId, customerId, candidates) {
|
||||
if (!Array.isArray(candidates) || !candidates.length) throw new Error('F40缺少本片真实候选')
|
||||
const ids = new Set(), batchAllocations = []
|
||||
const positive = value => /^[1-9]\d*$/.test(id(value))
|
||||
const cargo = candidates.map(candidate => {
|
||||
const selection = candidate?.selection
|
||||
if (!selection || selection.sourceType !== 'INBOUND_PLAN' || !positive(selection.planPalletId)
|
||||
|| !positive(selection.warehousePalletId) || selection.returnReceiptId != null || selection.relabelNo != null
|
||||
|| candidate.sourceKey !== `PLAN:${id(selection.planPalletId)}` || ids.has(candidate.sourceKey)
|
||||
|| !/^[a-f\d]{64}$/i.test(selection.sourceVersion || '') || candidate.sourceVersion !== selection.sourceVersion
|
||||
|| id(candidate.customerId) !== id(customerId) || id(candidate.warehouseId) !== id(warehouseId)
|
||||
|| quantity(candidate.availablePackages) == null || quantity(candidate.availablePackages) <= 0
|
||||
|| candidate.destination?.type !== 'FBA' || !candidate.destination.key
|
||||
|| !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length
|
||||
|| !Array.isArray(candidate.appointmentBatches)) throw new Error('F40本片候选身份、库存或冻结版本不完整')
|
||||
ids.add(candidate.sourceKey)
|
||||
if (candidate.appointmentBatches.length) {
|
||||
if (candidate.appointmentBatches.length !== 1) throw new Error('F40跨批次缺少本轮真实逐票分配,不推算')
|
||||
const batch = candidate.appointmentBatches[0]
|
||||
if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(customerId)
|
||||
|| !Array.isArray(batch.sources) || !batch.sources.length
|
||||
|| batch.sources.some(source => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(source.sourceKey || '')
|
||||
|| source.destinationKey !== candidate.destination.key || quantity(source.packageCount) == null || quantity(source.packageCount) <= 0)
|
||||
|| batch.sources.reduce((sum, source) => sum + quantity(source.packageCount), 0) !== quantity(candidate.availablePackages))
|
||||
throw new Error('F40预约批次与本片逐票实物不一致')
|
||||
for (const source of batch.sources) batchAllocations.push({ planPalletId: id(selection.planPalletId), batchKey: batch.batchKey,
|
||||
customerId: id(customerId), sourceKey: source.sourceKey, destinationKey: source.destinationKey,
|
||||
packageCount: quantity(source.packageCount), recordVersion: wireVersion(batch.recordVersion) })
|
||||
}
|
||||
return { sourceType: selection.sourceType, planPalletId: id(selection.planPalletId),
|
||||
warehousePalletId: id(selection.warehousePalletId), returnReceiptId: null, relabelNo: null, sourceVersion: selection.sourceVersion }
|
||||
})
|
||||
return { idempotencyKey: `${marker}-CREATE`.slice(0, 200), warehouseId: id(warehouseId), vehicle: null, driver: null,
|
||||
plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-F40-native`, cargo, batchAllocations }
|
||||
}
|
||||
/** 调用方可提供只读实测读取器;不按箱数或计费重量构造物理实载值。 */
|
||||
export async function resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) {
|
||||
const source = context.context?.f40LoadingMeasurements
|
||||
if (typeof source === 'function') return source({ environment: 'TEST', marker: context.marker, dispatchNo,
|
||||
warehouseId: id(warehouseId), sourceInboundId: id(producer.sourceInboundId), inboundCode: producer.inboundCode })
|
||||
return source?.[dispatchNo]
|
||||
}
|
||||
export function validFragmentCabinetIdentity(workbench, fragment, task) {
|
||||
const container = workbench?.container
|
||||
@@ -129,7 +170,7 @@ export function fulfillmentHistorySnapshot(detail) {
|
||||
return {
|
||||
orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId),
|
||||
parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole,
|
||||
status: detail?.order?.status, version: Number(detail?.order?.version),
|
||||
status: detail?.order?.status, version: detail?.order?.version,
|
||||
inboundCode: detail?.order?.inboundCode, orderNo: detail?.order?.orderNo,
|
||||
rawOrder: detail?.order,
|
||||
histories: (detail?.statusHistory || []).map(row => ({ ...row, id: id(row.id) })),
|
||||
@@ -443,61 +484,110 @@ async function fragment(context, source, rootOrderId, sequence) {
|
||||
shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt,
|
||||
palletLabelManifest, rootOrderId }
|
||||
}
|
||||
async function sendFragment(context, producer, picker, merchantToken, index) {
|
||||
const customerId = producer.tenantId
|
||||
const candidates = await api(context, 'GET', '读取该来源入仓码实收排车候选', `${DISPATCH}/candidates?sourceStage=RECEIVED`)
|
||||
const matches = (Array.isArray(candidates) ? candidates : []).filter(row => row.sourceStage === 'RECEIVED'
|
||||
&& row.inboundCode === producer.inboundCode && id(row.overseasWarehouseId) === id(picker.warehouseId))
|
||||
check(context, 'F40 只选择该根履约片的真实实收托盘', matches.length > 0
|
||||
&& matches.every(row => Number(row.packageCount) > 0), `inboundCode=${producer.inboundCode}, pallets=${matches.length}`)
|
||||
const dispatch = await api(context, 'POST', '该履约片创建独立真实排车', `${DISPATCH}/pre-arrange`, {
|
||||
planPalletIds: matches.map(row => id(row.planPalletId)),
|
||||
})
|
||||
const dispatchNo = dispatch.dispatchNo
|
||||
const vehicle = {
|
||||
vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机',
|
||||
driverPhone: '19900000000', carrierName: 'F40测试车队',
|
||||
estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)),
|
||||
}
|
||||
await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle)
|
||||
const transfer = await api(context, 'POST', '客户正式创建该片派送单',
|
||||
query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }),
|
||||
f40TransferBody(context.marker,index,vehicle), merchantToken)
|
||||
const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : []
|
||||
check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`)
|
||||
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
|
||||
const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath)
|
||||
check(context, 'F40 派送实际物品来源和一箱数量严格匹配', detail.order?.customerId === customerId
|
||||
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode)
|
||||
&& detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`)
|
||||
const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery,
|
||||
verifyDepartureReadiness, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext }
|
||||
|
||||
export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) {
|
||||
const { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness,
|
||||
saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides }
|
||||
const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId)
|
||||
context.marker = `${context.context.f40BaseMarker}-SEND-${index}`
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, picker.warehouseId)
|
||||
check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`)
|
||||
const tripVersion = await confirmFleetLoading(context, MODULE, tripProof)
|
||||
check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`)
|
||||
const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`,
|
||||
{ recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }, picker.token)
|
||||
check(context, 'F40 整车发车包含本片排车', departed.status === 'IN_TRANSIT' && departed.tripNo === tripProof.tripNo
|
||||
&& departed.dispatches?.some(item => item.dispatchNo === dispatchNo), `dispatch=${dispatchNo}`)
|
||||
const outbound = await api(context, 'GET', '回查实际来源出库完成',
|
||||
query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' }))
|
||||
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED'
|
||||
&& outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode),
|
||||
`outbound=${transfer.outboundNo}`)
|
||||
const beforeDelivery = await api(context, 'GET', '读取本片派送推进前版本', deliveryPath)
|
||||
const delivering = await api(context,'PUT','实际派送推进配送中',
|
||||
query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}),
|
||||
{status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker})
|
||||
check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`)
|
||||
const completed = await withDeliveryEventRecovery(context, {
|
||||
marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id),
|
||||
sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId),
|
||||
}, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index))
|
||||
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
|
||||
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
|
||||
const tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
|
||||
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure }
|
||||
const rootProof = context.context.f40RootFulfillmentProof
|
||||
check(context, 'F40 排车具有本轮根来源生产回执', Boolean(rootProof && producer.inboundCode && id(producer.sourceInboundId)
|
||||
&& rootProof.fragments?.some(row => id(row.sourceInboundId) === id(producer.sourceInboundId)
|
||||
&& row.inboundCode === producer.inboundCode && id(row.tenantId) === customerId
|
||||
&& id(row.containerBookingId) === id(producer.containerBookingId))), 'actual-root-source')
|
||||
const dispatchProof = { marker: context.marker, customerId, warehouseId, sourceInboundId: id(producer.sourceInboundId),
|
||||
inboundCode: producer.inboundCode, dispatchNo: null, stage: 'PREPARING' }
|
||||
;(rootProof.dispatches ||= []).push(dispatchProof)
|
||||
// 复用同一已验证的写前落盘/未知停写助手;证据对象也保留在根单证明中。
|
||||
context.createdResources.phase5OverseasFulfillment = dispatchProof
|
||||
context = await fulfillmentEvidenceContext(context)
|
||||
try {
|
||||
const candidates = await api(context, 'GET', '读取该来源入仓码原生实收排车候选',
|
||||
query(`${DISPATCH}/candidates`, { warehouseId }))
|
||||
check(context, 'F40 正式候选列表完整', candidates?.complete === true && Array.isArray(candidates.candidates), 'native-complete-candidate-list')
|
||||
const matches = candidates.candidates.filter(row => row.selection?.sourceType === 'INBOUND_PLAN'
|
||||
&& row.inboundCode === producer.inboundCode && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId)
|
||||
check(context, 'F40 只选择本片完整真实实收候选且总量一箱', candidates?.complete === true && matches.length > 0
|
||||
&& matches.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 1, `inboundCode=${producer.inboundCode}, pallets=${matches.length}`)
|
||||
dispatchProof.candidates = matches
|
||||
const createBody = f40CreateDispatchBody(context.marker, warehouseId, customerId, matches)
|
||||
const dispatch = await api(context, 'POST', '该履约片通过原生创建独立真实整车', DISPATCH, createBody)
|
||||
const dispatchNo = dispatch?.dispatchNo
|
||||
check(context, 'F40 创建返回原生整车业务身份及装车前阶段', Boolean(dispatchNo) && dispatch.status === 'ACTIVE'
|
||||
&& dispatch.stage === 'BEFORE_LOADING' && validVersion(dispatch.recordVersion), 'native-create-result')
|
||||
dispatchProof.dispatchNo = dispatchNo; dispatchProof.createResult = dispatch
|
||||
const createReplay = await api(context, 'POST', 'F40 原生建单原命令重放', DISPATCH, createBody)
|
||||
check(context, 'F40 建单重放只恢复原版本及业务结果', createReplay.replayed === true && createReplay.dispatchNo === dispatchNo
|
||||
&& String(createReplay.recordVersion) === String(dispatch.recordVersion) && JSON.stringify(createReplay.result) === JSON.stringify(dispatch.result), 'immutable-create-result')
|
||||
dispatchProof.action = 'PREPARE_AND_REVIEW'
|
||||
const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId)
|
||||
dispatchProof.trip = tripProof
|
||||
dispatchProof.action = 'PDA_PICK'
|
||||
check(context, 'F40 正式 PDA 拣货核平仍待显式装车', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`)
|
||||
dispatchProof.action = 'LOADING_COMPLETE'
|
||||
const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId)
|
||||
await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements)
|
||||
dispatchProof.action = 'ARRANGE_DELIVERY'
|
||||
const transfer = await arrangeDispatchDelivery(context, dispatchNo)
|
||||
check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`)
|
||||
const deliveries = transfer.deliveries, outbounds = transfer.outbounds
|
||||
check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1
|
||||
&& id(deliveries[0].customerId) === customerId && id(outbounds[0].customerId) === customerId
|
||||
&& deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1
|
||||
&& deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination')
|
||||
const outboundNo = outbounds[0].outboundNo
|
||||
dispatchProof.arrangement = transfer.data?.result; dispatchProof.outboundNo = outboundNo
|
||||
dispatchProof.deliveryNo = deliveries[0].deliveryNo
|
||||
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId })
|
||||
const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath)
|
||||
check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId
|
||||
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode)
|
||||
&& detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`)
|
||||
dispatchProof.action = 'PDA_DEPART'
|
||||
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
|
||||
check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`)
|
||||
const departBody = { recordVersion: wireVersion(ready.data.recordVersion),
|
||||
idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }
|
||||
const departed = await api(context, 'POST', 'F40 正式 PDA 实际发车', `${PDA}/${dispatchNo}/depart`, departBody, picker.token)
|
||||
check(context, 'F40 本片原生发车进入DELIVERING并且真实出库一箱', departed.dispatchNo === dispatchNo
|
||||
&& departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 1
|
||||
&& departed.result?.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === deliveries[0].deliveryNo, `dispatch=${dispatchNo}`)
|
||||
const departureReplay = await api(context, 'POST', 'F40 实际发车原命令重放', `${PDA}/${dispatchNo}/depart`, departBody, picker.token)
|
||||
check(context, 'F40 发车重放保留原公共版本及全部发运结果', departureReplay.replayed === true
|
||||
&& String(departureReplay.recordVersion) === String(departed.recordVersion)
|
||||
&& JSON.stringify(departureReplay.result) === JSON.stringify(departed.result), 'immutable-departure-result')
|
||||
dispatchProof.departure = departed; dispatchProof.departureReplay = departureReplay; dispatchProof.stage = 'DELIVERING'
|
||||
const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`)
|
||||
check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId
|
||||
&& outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED'
|
||||
&& outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), `outbound=${outboundNo}`)
|
||||
const beforeDelivery = await api(context, 'GET', '回查实际发车推进本片派送及其版本', deliveryPath)
|
||||
check(context, 'F40 实际发车已推进派送DELIVERING且身份未漂移', beforeDelivery.order?.status === 'DELIVERING'
|
||||
&& id(beforeDelivery.order.customerId) === customerId && beforeDelivery.order.outboundNo === outboundNo
|
||||
&& validVersion(beforeDelivery.order.version), 'actual-departure-delivery-facts')
|
||||
dispatchProof.action = 'SAVE_RECEIPT_AND_EVENT_RECOVERY'
|
||||
const completed = await withDeliveryEventRecovery(context, {
|
||||
marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id),
|
||||
sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId),
|
||||
}, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index))
|
||||
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
|
||||
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
|
||||
const nativeCompletion = await waitState(() => api(context, 'GET', 'F40 回查本片全部正常签收汇总阶段', `${DISPATCH}/${dispatchNo}`),
|
||||
value => value?.status === 'ACTIVE' && value?.stage === 'DELIVERED')
|
||||
check(context, 'F40 正常签收自动汇总原生整车DELIVERED', nativeCompletion?.dispatchNo === dispatchNo
|
||||
&& nativeCompletion.status === 'ACTIVE' && nativeCompletion.stage === 'DELIVERED', 'actual-normal-signature-completion')
|
||||
dispatchProof.normalCompletion = nativeCompletion; dispatchProof.stage = 'DELIVERED'
|
||||
const tripClosure = await finishFleetTrip(context, MODULE, tripProof)
|
||||
dispatchProof.tripClosure = tripClosure
|
||||
await context.checkpoint()
|
||||
return { producer, dispatchNo, outboundNo, deliveryNo: deliveries[0].deliveryNo,
|
||||
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure }
|
||||
} catch (error) {
|
||||
dispatchProof.stage = 'FAILED'; dispatchProof.failedAction = dispatchProof.action || 'CREATE'
|
||||
try { await context.checkpoint() } finally { throw error }
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */
|
||||
@@ -763,7 +853,7 @@ async function runRootFulfillment(context) {
|
||||
revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)}
|
||||
check(scope,'F40 新键前原签收事实保持完整稳定',
|
||||
JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`)
|
||||
const saveBody = {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}
|
||||
const saveBody = {expectedVersion:wireVersion(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}
|
||||
const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}}
|
||||
;(proof.receiptReplayProofs ||= []).push(receiptProof)
|
||||
const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实',
|
||||
|
||||
Reference in New Issue
Block a user