fix(fleet): verify dispatch cargo and retain checked costs on continuation

This commit is contained in:
2026-10-03 20:24:56 +08:00
parent f56c68d952
commit f2e25ec34b
5 changed files with 109 additions and 34 deletions
+4
View File
@@ -38,6 +38,10 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_
`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-<run-id>/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-<run-id>/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。
该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,仅接受全新批次。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。
经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。
`order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。
`fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 提供,数据库凭据只在执行节点内存读取。 `fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 提供,数据库凭据只在执行节点内存读取。
@@ -12,8 +12,9 @@ function fixture(fault = '') {
const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT', 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 } 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', const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ inboundCode: `IN-${customer}`, packageCount: 10 }] })) status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport,
const liveDispatches = dispatchDetails.map(row => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] }))
const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 }))
const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' })) const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' }))
const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo,
pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } } pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } }
@@ -24,6 +25,8 @@ function fixture(fault = '') {
const conflict = message => ({ http: 200, body: { code: 'A0409', message }, 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 bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) }
const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId })) 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 context = { const context = {
context: { async checkpointFleetHandover() {} }, context: { async checkpointFleetHandover() {} },
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
@@ -207,7 +210,7 @@ test('every HTTP call observes a completed pre-send checkpoint; post-response di
assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING') assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING')
}) })
for (const [fault, message] of Object.entries({ 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', for (const [fault, message] of Object.entries({ 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用',
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) {
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
@@ -61,7 +61,7 @@ function io(context, module, proof) {
return { check, request } return { check, request }
} }
function dispatchFacts(row) { function dispatchFacts(row) {
const fields = ['dispatchNo', 'customerId', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount',
'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items']
return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]]))
} }
@@ -113,9 +113,13 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke
for (const expected of dispatchDetails) { for (const expected of dispatchDetails) {
const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库')
const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo)
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' check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED'
&& Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount)
&& text(row.customerId) === text(binding.customerId) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) && 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)) proof.dispatchBefore.push(dispatchFacts(row))
const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) 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 check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo
+52 -28
View File
@@ -154,8 +154,8 @@ export function parseFleetArguments(args) {
} }
if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)
|| (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required')
if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256)
throw Error('Departed recovery requires the exact reviewed report SHA256') throw Error('Departed recovery requires the exact reviewed report SHA256')
if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports')
if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport)) if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport))
@@ -168,9 +168,13 @@ export function fleetExecutionPlan(config) {
stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null,
outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites',
resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null,
stages: config.resumeStage === 'DEPARTED_BEFORE_COSTS' stages: config.resumeStage?.startsWith('DEPARTED_')
? ['Verify reviewed report SHA256', 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', ? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
'Create fresh bounded cost/PDA sessions', 'Original trip operating costs with actual allocation availability', ? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries'
: 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries',
config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions',
config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability',
'Retain original DEPART; no picking or departure commands',
'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions']
: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle',
'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking',
@@ -183,19 +187,26 @@ export function fleetExecutionPlan(config) {
export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0]
const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo)
if (stage === 'DEPARTED_BEFORE_COSTS') { if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER') {
const afterCosts = stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false) const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false)
if (report?.success !== false || proof?.stage !== 'TRIP_OPERATING_COSTS' || proof.status !== 'CHECKING' const failureName = afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘'
const handover = trip?.handover
if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING'
|| proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo
|| !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash
|| costs.status !== 'FAILED' || !Array.isArray(costs.costs) || costs.costs.length || !costs.requests?.length || costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length
|| costs.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') || costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId
|| trip.handover || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch || (afterCosts ? ![200, 403].includes(row.http) || !['200', 'A0403', 'A0409'].includes(id(row.code)) : row.method !== 'GET' || row.http !== 200 || id(row.code) !== '200'))
|| (afterCosts ? handover?.status !== 'FAILED' || !handover.requests?.length || handover.confirmRequest || handover.selection
|| handover.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200')
|| !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover)
|| trip.closure || proof.receipts || proof.closure || trip.returnRedispatch
|| resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId || resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId
|| failures?.length !== 2 || !failures.some(row => row.name === '独立预期覆盖全部实物且没有重叠托盘') || failures?.length !== 2 || !failures.some(row => row.name === failureName)
|| failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].includes(row.name))) || failures.some(row => ![failureName, '模块执行'].includes(row.name)))
throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes') throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes')
return { resources, marker: proof.marker, departed: true } return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts }
} }
if (stage === 'TERMINAL_READONLY') { if (stage === 'TERMINAL_READONLY') {
const failures = report?.steps?.filter(row => row.passed === false) const failures = report?.steps?.filter(row => row.passed === false)
@@ -298,7 +309,7 @@ export async function verifyFleetTerminalState(context) {
} }
/** All business checks happen before creating replacement test sessions or issuing a business command. */ /** All business checks happen before creating replacement test sessions or issuing a business command. */
export async function verifyDepartedRecovery(context) { export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false } = {}) {
const proof = context.createdResources.fleetAcceptance const proof = context.createdResources.fleetAcceptance
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad
@@ -324,7 +335,8 @@ export async function verifyDepartedRecovery(context) {
&& departures?.length === 1 && same(departures[0], original) && departures?.length === 1 && same(departures[0], original)
&& history.records.every(row => BigInt(row.recordVersion) <= BigInt(original.recordVersion)), 'immutable DEPART/history changed') && history.records.every(row => BigInt(row.recordVersion) <= BigInt(original.recordVersion)), 'immutable DEPART/history changed')
const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`) const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`)
check(costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0 check((costsAlreadyVerified ? same(costs, trip.costAcceptance.final)
: costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0)
&& costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed') && costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed')
check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists')
check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort())
@@ -638,6 +650,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
} }
export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady,
costsAlreadyVerified = false,
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId
@@ -646,8 +659,10 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa
const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) } const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) }
const check = (label, ok) => { if (!ok) throw Error(label) } const check = (label, ok) => { if (!ok) throw Error(label) }
async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data } async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data }
stage('TRIP_OPERATING_COSTS') if (!costsAlreadyVerified) {
await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) stage('TRIP_OPERATING_COSTS')
await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
} else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED')
stage('IN_TRANSIT_HANDOVER') stage('IN_TRANSIT_HANDOVER')
await handover(context, trip, picker.token, dispatchDetails, { await handover(context, trip, picker.token, dispatchDetails, {
onReady: onHandoverReady ? async fixture => { onReady: onHandoverReady ? async fixture => {
@@ -681,31 +696,39 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa
} }
export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors,
costsAlreadyVerified = false,
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
complete = completeDepartedFleetScenario } = {}) { complete = completeDepartedFleetScenario } = {}) {
const verified = await verify(context) const verified = await verify(context, { costsAlreadyVerified })
const proof = context.createdResources.fleetAcceptance, trip = verified.trip const proof = context.createdResources.fleetAcceptance, trip = verified.trip
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors if (!costsAlreadyVerified) {
trip.previousReadOnlyCostAttempts = [trip.costAcceptance] context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
trip.previousReadOnlyCostAttempts = [trip.costAcceptance]
delete context.createdResources.fleetCostActors
delete trip.costAcceptance
} else {
trip.previousReadOnlyHandoverAttempts = [trip.handover]
delete trip.handover
}
proof.previousPicker = proof.picker proof.previousPicker = proof.picker
delete context.createdResources.fleetCostActors
delete trip.costAcceptance
delete proof.picker delete proof.picker
proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}` proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}`
await context.checkpoint() await context.checkpoint()
let actors, picker let actors, picker
try { try {
actors = await prepareActors(context) if (!costsAlreadyVerified) {
if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken actors = await prepareActors(context)
if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken
|| actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token)) || actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token))
throw Error('Recovery requires fresh independent cost sessions') throw Error('Recovery requires fresh independent cost sessions')
}
const pickerContext = Object.create(context); pickerContext.marker = proof.pickerMarker const pickerContext = Object.create(context); pickerContext.marker = proof.pickerMarker
const access = await resolveAccess(context) const access = await resolveAccess(context)
picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null
if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session') if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session')
proof.picker = { membershipId: picker.membershipId, username: picker.username } proof.picker = { membershipId: picker.membershipId, username: picker.username }
await context.checkpoint() await context.checkpoint()
return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors }) return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified })
} finally { } finally {
try { await closePicker(context, picker, 71) } finally { await actors?.close() } try { await closePicker(context, picker, 71) } finally { await actors?.close() }
} }
@@ -738,7 +761,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous) const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous)
if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports') if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports')
const bytes = readFileSync(previous) const bytes = readFileSync(previous)
if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) if (config.resumeStage?.startsWith('DEPARTED_') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256)
throw Error('Reviewed departure report SHA256 mismatch; no environment request sent') throw Error('Reviewed departure report SHA256 mismatch; no environment request sent')
resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage) resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage)
save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage }) save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage })
@@ -774,7 +797,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
} }
context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance)
const fleetScenario = async (ctx, options = {}) => { const fleetScenario = async (ctx, options = {}) => {
if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors }) if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, costsAlreadyVerified: options.costsAlreadyVerified })
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
try { return await scenario(ctx, { ...options, costActors }) } try { return await scenario(ctx, { ...options, costActors }) }
finally { await costActors?.close() } finally { await costActors?.close() }
@@ -801,6 +824,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || ''
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
resumeDeparted: resumed.departed, resumeDeparted: resumed.departed,
costsAlreadyVerified: resumed.costsAlreadyVerified,
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules,
+41 -1
View File
@@ -15,7 +15,7 @@ function departedRecoveryFixture() {
dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' }, dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' },
departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } } departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } }
const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure, const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure,
requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] }, requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't' }] },
externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } } externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } }
const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }], const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }],
createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } } createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } }
@@ -38,6 +38,46 @@ function departedRecoveryFixture() {
return { report, ctx, calls, responses, base, trip } return { report, ctx, calls, responses, base, trip }
} }
function afterCostsRecoveryFixture() {
const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture
report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER'
report.steps[0].name = '每张排车已发车且货量与客户起运仓一致'
trip.costAcceptance.status = 'VERIFIED'
trip.costAcceptance.costs = [{ costNo: 'CNY-COST' }, { costNo: 'USD-COST' }]
trip.costAcceptance.final = { page: { records: structuredClone(trip.costAcceptance.costs), total: 2 },
summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [{ currency: 'CNY', directCost: '100.03' }, { currency: 'USD', directCost: '13.07' }], allocationBlockReason: 'MULTI_STOP' } }
trip.handover = { status: 'FAILED', requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] }
responses.set(base + '/costs?page=1&size=100', structuredClone(trip.costAcceptance.final))
return fixture
}
test('after-cost recovery requires its exact known read-only handover failure and rejects any handover write', () => {
const { report } = afterCostsRecoveryFixture()
assert.equal(reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER').costsAlreadyVerified, true)
report.createdResources.fleetTrips[0].handover.requests.push({ method: 'POST', status: 'RESPONDED', http: 200, code: '200' })
assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/)
})
test('after-cost recovery checks original cost contents and refuses a changed amount before any write', async () => {
const { ctx, responses, base } = afterCostsRecoveryFixture()
responses.get(base + '/costs?page=1&size=100').summary.totals[0].directCost = '100.04'
await assert.rejects(continueReviewedDeparture(ctx, { costsAlreadyVerified: true,
prepareActors: () => assert.fail('no new actors'), makePicker: () => assert.fail('no new PDA before verification') }), /costs already written or cargo basis changed/)
})
test('after-cost recovery retains verified costs and only enters handover, signatures and resource closure', async () => {
const { ctx, trip } = afterCostsRecoveryFixture(), costs = trip.costAcceptance, oldHandover = trip.handover, operations = []
await continueReviewedDeparture(ctx, { costsAlreadyVerified: true, prepareActors: () => assert.fail('verified costs need no new cost actors'),
resolveAccess: async () => ({}), makePicker: async () => ({ token: 'new-pda', membershipId: 'NEW-PDA' }), closePicker: async () => operations.push('close-pda'),
complete: (context, trip, picker, details, options) => completeDepartedFleetScenario(context, trip, picker, details, { ...options,
verifyCosts: () => assert.fail('costs must never be recreated'), handover: async () => operations.push('handover'),
completeDelivery: async (_ctx, order) => { operations.push('sign:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } },
closeTrip: async () => { operations.push('release'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, cleanup: async () => operations.push('cleanup'),
}) })
assert.equal(trip.costAcceptance, costs); assert.equal(trip.previousReadOnlyHandoverAttempts[0], oldHandover)
assert.deepEqual(operations, ['handover', 'sign:DL-OWN', 'sign:DL-EXT', 'release', 'cleanup', 'close-pda'])
})
test('departed recovery requires reviewed SHA and exactly the original read-only cost failure', () => { test('departed recovery requires reviewed SHA and exactly the original read-only cost failure', () => {
assert.throws(() => parseFleetArguments(['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS']), /SHA256/) assert.throws(() => parseFleetArguments(['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS']), /SHA256/)
const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)] const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)]