feat(test): continue reviewed return source sample with pre-signature audit

This commit is contained in:
2026-10-03 22:13:15 +08:00
parent 6dbc899626
commit 0c8ede3889
5 changed files with 346 additions and 17 deletions
+5 -1
View File
@@ -38,10 +38,14 @@ 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。
该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,仅接受全新批次。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。
该入口用 `--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`。恢复必须另获当前执行放行,不由脚本自动启动。
`return-redispatch` 在新重派签收前必须等待来源事件只读审计:原余货已签、B 已完成、A 的 2 箱尚未重派签收时,入口持久保存 `before-redispatch-signature.json` 并打印 `FLEET_SIGNATURE_AUDIT_WAITING`,最多等待 20 分钟。总控先核对现场 A 完成事件数为 0、B 为 1,再在同一报告目录提供 `approve-before-redispatch-signature.json`;文件必须含当前 checkpoint 的 `gateNonce`、`marker`、`newTripNo`、`sourceAEventCount:0`、`sourceBEventCount:1`、`auditEvidencePath` 和 `auditEvidenceSha256`,且证据文件须位于仓库 `reports/` 内、哈希相符。签收在此之前不会发送;缺失、超时或证据不符即停止,不自动续跑。新签收及同键重放后,另查 A/B 各一条来源完成事件及 Firstleg 精确 ACK,最终快照不能代替前签时点证据。
`RETURN_SOURCE_SELECTION_BEFORE_RETURN` 仅用于 `--scenario return-redispatch` 的明确候选选择失败:原样本已完成唯一 DEPART、交接和成本验证,三张原派送均已通过明确成功响应进入 `DELIVERING`,退回助手只执行了候选 GET 和这三次开始派送,尚未标记退回、建立异常、创建新出库或签收。必须提供原报告精确 `--resume-sha256`,现场复查完整原 DEPART、交接和成本快照、当前车辆/司机身份版本及占用、三单状态版本和修复后的完整 A/B 候选。预检通过后仅新建绑定原仓的 PDA 会话,从原车次退回复查继续,保留原成本/发车/交接,不再次开始派送。前签来源审计闸口仍必须由总控解除;任何先前写入结果不明或现场变化均拒绝恢复,不自动重造。
`DEPARTED_VEHICLE_CREATED` 是独立的显式恢复阶段:只接受已验证成本、唯一接替车辆创建 POST 明确成功、随后仅因车牌大小写断言停止的报告。执行前核对精确报告 SHA、原成功响应、车辆完整当前快照,并通过 Jenkins 一致性只读查询确认该车辆在 `ll_fleet_resource_assignment` 中没有占用;缺少证据、车辆变更或占用均停止。已创建车辆显式传给交接助手,跳过原车辆创建 POST,从新司机及证照继续;原成本和 DEPART 保留。该成功写入不能借用只读失败恢复阶段忽略,也不得自动重发。
`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`,离线通过不代表真实故障注入已验收。
@@ -1,6 +1,10 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { selectReturnSources, verifyFleetReturnRedispatch } from './fleet-return-redispatch-acceptance.mjs'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { createHash } from 'node:crypto'
import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit } from './fleet-return-redispatch-acceptance.mjs'
function fixture(fault = '') {
const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = []
@@ -47,6 +51,11 @@ function fixture(fault = '') {
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) }
},
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) } },
@@ -56,6 +65,24 @@ function fixture(fault = '') {
}
const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies)
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 () => {
const f = fixture(), result = await run(f)
assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED')
@@ -68,6 +95,42 @@ test('fresh return/redispatch chain proves A pending, B complete, then A complet
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))
})
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 fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test(`file gate requires exact checkpoint and hashed source audit: ${fault}`, async () => {
const root = mkdtempSync(join(tmpdir(), 'fleet-signature-gate-')), output = join(root, 'reports', 'run')
mkdirSync(output, { recursive: true })
const context = { marker: 'CURRENT', context: { fleetRunId: 'FRESH' }, createdResources: { fleetAcceptance: { tripNo: 'TR-ORIGINAL', customerId: 'C' } } }
const proof = { newTripNo: 'TR-NEW', sources: { A: { tail: { sourceInboundId: 'A' } }, B: { tail: { sourceInboundId: 'B' } } } }
try {
const wait = async () => {
const checkpoint = JSON.parse(readFileSync(join(output, 'before-redispatch-signature.json'), 'utf8'))
assert.equal(checkpoint.proof.newTripNo, 'TR-NEW'); assert.equal(checkpoint.sourceAInboundId, 'A')
const evidence = Buffer.from('{"A":0,"B":1}')
writeFileSync(join(root, 'reports', 'audit.json'), evidence)
writeFileSync(join(output, 'approve-before-redispatch-signature.json'), JSON.stringify({ gateNonce: fault === 'nonce' ? 'OTHER' : checkpoint.gateNonce,
marker: checkpoint.marker, newTripNo: fault === 'trip' ? 'OTHER' : checkpoint.newTripNo,
sourceAEventCount: fault === 'counts' ? 1 : 0, sourceBEventCount: 1, auditEvidencePath: 'reports/audit.json',
auditEvidenceSha256: fault === 'hash' ? 'a'.repeat(64) : createHash('sha256').update(evidence).digest('hex') }))
}
const promise = waitForRedispatchSignatureAudit(context, proof, { root, output, wait, timeoutMs: fault === 'timeout' ? 0 : 5000 })
if (fault === 'none') { const result = await promise; assert.equal(result.sourceAEventCount, 0); assert.equal(result.sourceBEventCount, 1) }
else await assert.rejects(promise, /gate|approval|SHA256/)
} finally {
assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(resolve(root).startsWith(join(resolve(tmpdir()), 'fleet-signature-gate-')))
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')
@@ -1,3 +1,6 @@
import {existsSync,openSync,writeFileSync,fsyncSync,closeSync,readFileSync} from 'node:fs'
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'
@@ -30,7 +33,7 @@ export function selectReturnSources(rows) {
return { a, b }
}
/** Fresh-run-only stage. Every helper HTTP request is checkpointed before dispatch, including multipart metadata. */
/** 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 (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker
@@ -71,6 +74,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
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 customerId = id(input.customerId), warehouseId = id(input.warehouseId)
const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId))
@@ -98,6 +102,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
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))
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)
@@ -186,6 +191,15 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
const departedAgain = await request('POST', departPath, departBody, input.pickerToken)
check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2)
const newDelivery = await delivery(transfer.deliveries[0].deliveryNo)
proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo,
departed, originalSignatureCount: proof.originalSignatures.length }
await context.checkpoint()
check('Readonly source event audit gate is required before redispatch signature', typeof deps.onBeforeRedispatchSignature === 'function')
const audit = await deps.onBeforeRedispatchSignature(proof)
check('Pre-signature audit proves A has zero completion events and B has one', audit?.sourceAEventCount === 0
&& 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)
check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2)
const afterA = await tail(a.relation), afterB = await tail(b.relation)
@@ -202,3 +216,37 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input,
await context.checkpoint(); return proof
} catch (error) { proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error }
}
export async function waitForRedispatchSignatureAudit(context, proof, { root, output, secrets = [],
wait = ms => new Promise(resolve => setTimeout(resolve, ms)), timeoutMs = 20 * 60 * 1000 } = {}) {
const gateNonce = randomUUID(), approvalPath = join(output, 'approve-before-redispatch-signature.json')
const checkpointPath = join(output, 'before-redispatch-signature.json')
const checkpoint = { gateNonce, marker: context.marker, runId: context.context.fleetRunId,
originalTripNo: context.createdResources.fleetAcceptance.tripNo, newTripNo: proof.newTripNo,
sourceAInboundId: proof.sources.A.tail.sourceInboundId, sourceBInboundId: proof.sources.B.tail.sourceInboundId,
customerId: context.createdResources.fleetAcceptance.customerId, proof, createdResources: context.createdResources }
const descriptor = openSync(checkpointPath, 'wx')
try { writeFileSync(descriptor, JSON.stringify(redactEvidence(checkpoint, secrets), null, 2) + '\n'); fsyncSync(descriptor) }
finally { closeSync(descriptor) }
const progressDescriptor = openSync(join(output, 'progress.json'), 'w')
try {
writeFileSync(progressDescriptor, JSON.stringify(redactEvidence({ stage: 'RETURN_REDISPATCH_PRE_SIGNATURE', marker: context.marker,
steps: context.steps || [], modules: context.modules || [], createdResources: context.createdResources }, secrets), null, 2) + '\n')
fsyncSync(progressDescriptor)
} finally { closeSync(progressDescriptor) }
console.log('FLEET_SIGNATURE_AUDIT_WAITING=' + JSON.stringify({ checkpointPath, approvalPath, gateNonce, marker: context.marker }))
const started = Date.now()
while (!existsSync(approvalPath)) {
if (Date.now() - started >= timeoutMs) throw Error('Pre-signature audit gate timed out; do not sign or resume automatically')
await wait(1000)
}
const approval = JSON.parse(readFileSync(approvalPath, 'utf8'))
if (approval.gateNonce !== gateNonce || approval.marker !== checkpoint.marker || approval.newTripNo !== checkpoint.newTripNo
|| approval.sourceAEventCount !== 0 || approval.sourceBEventCount !== 1 || !/^[a-f0-9]{64}$/.test(approval.auditEvidenceSha256 || '')
|| !/^reports\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)?\.json$/.test(approval.auditEvidencePath || ''))
throw Error('Pre-signature approval does not match this exact gate and source counts')
const evidencePath = resolve(root, approval.auditEvidencePath)
if (!existsSync(evidencePath) || createHash('sha256').update(readFileSync(evidencePath)).digest('hex') !== approval.auditEvidenceSha256)
throw Error('Pre-signature audit evidence SHA256 mismatch')
return approval
}
+143 -12
View File
@@ -13,7 +13,7 @@ import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-exter
import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs'
import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs'
import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs'
import { verifyFleetReturnRedispatch } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs'
import { verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit, selectReturnSources } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs'
import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
@@ -154,11 +154,12 @@ export function parseFleetArguments(args) {
}
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')
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', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256)
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', 'DEPARTED_VEHICLE_CREATED', 'RETURN_SOURCE_SELECTION_BEFORE_RETURN'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
if (config.resumeStage?.startsWith('DEPARTED_') || config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256)
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 (!['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 && config.resumeStage !== 'RETURN_SOURCE_SELECTION_BEFORE_RETURN')
|| (config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' && config.scenario !== 'return-redispatch'))
throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint')
return config
}
@@ -169,7 +170,12 @@ 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,
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,
stages: config.resumeStage?.startsWith('DEPARTED_')
stages: config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN'
? ['Verify exact source-selection-failure report SHA256 and three definitive delivery starts', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions',
'Recheck repaired A/B return candidates and complete source quantities before any write', 'Create fresh bound PDA session',
'Original sample actual return and redispatch; no repeated original departure/handover/cost/start-delivery',
'Wait for coordinator pre-signature A0/B1 event evidence gate', 'New signature and source ACK evidence; close new and original resources']
: config.resumeStage?.startsWith('DEPARTED_')
? ['Verify reviewed report SHA256', afterCosts
? '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',
@@ -189,6 +195,30 @@ export function fleetExecutionPlan(config) {
export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0]
const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo)
if (stage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') {
const attempt = trip?.returnRedispatch, originals = attempt?.originalDeliveries, writes = attempt?.requests?.filter(row => row.method !== 'GET')
const failures = report?.steps?.filter(row => row.passed === false)
if (report?.success !== false || proof?.stage !== 'RETURN_REDISPATCH' || proof.status !== 'CHECKING'
|| proof.marker !== attempt?.marker || trip?.costAcceptance?.status !== 'VERIFIED' || trip.handover?.status !== 'VERIFIED'
|| proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip.tripNo
|| trip.closure || proof.closure || proof.receipts?.length || attempt.status !== 'FAILED'
|| attempt.failure !== 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B'
|| attempt.exceptionNo || attempt.sources || attempt.newTripNo || attempt.originalSignatures
|| originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 3
|| originals.some(row => id(row.customerId) !== id(proof.customerId) || id(row.overseasWarehouseId) !== id(proof.warehouseId)
|| !proof.dispatchNos.includes(row.dispatchNo) || row.status !== 'WAIT_DELIVERY' || row.signedAt || Number(row.signedPackages || 0))
|| attempt.requests.some(row => row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200' || !row.requestId || !row.traceId)
|| writes.some(row => {
const original = originals.find(item => item.deliveryNo === row.result?.deliveryNo)
return !original || row.method !== 'PUT' || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}/status?customerId=${proof.customerId}`
|| row.body?.status !== 'DELIVERING' || id(row.body.expectedVersion) !== id(original.version)
|| row.result.deliveryStatus !== 'DELIVERING' || row.result.replayed !== false
}) || new Set(writes.map(row => row.result?.deliveryNo)).size !== 3
|| resources.fleetCostActors?.status !== 'DISABLED' || failures?.length !== 1 || failures[0].name !== '模块执行'
|| failures[0].evidence !== attempt.failure)
throw Error('Return source recovery requires the exact known candidate failure and three definitive delivery starts, with no return/signature writes')
return { resources, marker: proof.marker, returnSourceSelection: true }
}
if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') {
const vehicleCreated = stage === 'DEPARTED_VEHICLE_CREATED'
const afterCosts = stage !== 'DEPARTED_BEFORE_COSTS'
@@ -321,10 +351,11 @@ export async function verifyFleetTerminalState(context) {
}
/** All business checks happen before creating replacement test sessions or issuing a business command. */
export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle } = {}) {
export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle, returnSourceReady = false } = {}) {
const proof = context.createdResources.fleetAcceptance
const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo)
const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad
const expectedTrip = returnSourceReady ? trip.handover.finalTrip : snapshot.trip
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`
const evidence = { status: 'CHECKING', tripNo: trip.tripNo, requests: [], dispatchDetails: [], deliveries: [] }
context.createdResources.fleetDepartureRecoveryVerification = evidence
@@ -337,20 +368,43 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f
return r.body.data
}
const current = await get(base)
check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(snapshot.trip.recordVersion)
check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion)
&& current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId)
&& id(current.trip.vehicleId) === id(snapshot.trip.vehicleId) && id(current.trip.driverId) === id(snapshot.trip.driverId)
&& id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId)
&& !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed')
const history = await get(`${base}/history?page=1&size=100`)
const departures = history.records?.filter(row => row.action === 'DEPART')
check(Array.isArray(history.records) && Number(history.total) === history.records.length
&& 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(expectedTrip.recordVersion)), 'immutable DEPART/history changed')
if (returnSourceReady) {
const expectedHistory = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/history?`)).at(-1)?.result
check(expectedHistory && same(history.records, expectedHistory.records), 'verified handover history changed')
check(typeof context.context?.readRecoveryTripResources === 'function', 'current resource occupancy reader missing')
const bindings = await context.context.readRecoveryTripResources(trip.tripNo)
check(bindings.length === 2 && bindings.every(row => row.tripNo === trip.tripNo)
&& bindings.some(row => row.resourceKind === 'VEHICLE' && id(row.resourceId) === id(expectedTrip.vehicleId))
&& bindings.some(row => row.resourceKind === 'DRIVER' && id(row.resourceId) === id(expectedTrip.driverId)), 'current resources no longer belong to the original trip')
evidence.resourceBindings = bindings
const vehicleSnapshot = trip.handover.requests.find(row => row.method === 'POST'
&& row.path === '/api/platform/v1/last-leg/master-data/vehicles' && id(row.result?.id) === id(expectedTrip.vehicleId))?.result
const driverSnapshot = trip.handover.requests.find(row => row.method === 'POST'
&& row.path === `/api/platform/v1/last-leg/fleet/drivers/${expectedTrip.driverId}/review`)?.result
check(vehicleSnapshot && driverSnapshot, 'verified current resource snapshots missing')
const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${expectedTrip.vehicleId}`)
const driver = await get(`/api/platform/v1/last-leg/fleet/drivers/${expectedTrip.driverId}`)
check(same(vehicle, vehicleSnapshot), 'current vehicle snapshot changed')
check(['id', 'recordVersion', 'active', 'reviewStatus', 'licenseFileId', 'details'].every(field => same(driver[field], driverSnapshot[field])), 'current driver identity/version/qualification facts changed')
evidence.currentVehicle = vehicle; evidence.currentDriver = driver
}
const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`)
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')
check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists')
if (returnSourceReady) {
const expected = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/handovers?`)).at(-1)?.result
check(expected && Number(handovers.total) === 1 && same(handovers.records, expected.records), 'original verified handover changed')
} else check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists')
check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort())
&& current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed')
for (const number of proof.dispatchNos) {
@@ -369,8 +423,12 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f
for (const number of deliveryNos) {
const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`)
const order = detail.order
const started = returnSourceReady ? trip.returnRedispatch.requests.find(row => row.method === 'PUT' && row.result?.deliveryNo === number) : null
check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo)
&& order.status === 'WAIT_DELIVERY' && id(order.version) === '1' && !order.signedAt && !Number(order.signedPackages || 0)
&& order.status === (returnSourceReady ? 'DELIVERING' : 'WAIT_DELIVERY')
&& id(order.version) === (returnSourceReady ? String(Number(started?.body.expectedVersion) + 1) : '1')
&& (!returnSourceReady || order.vehiclePlate === expectedTrip.vehiclePlate && order.driverName === expectedTrip.driverName && order.driverPhone === expectedTrip.driverPhone)
&& !order.signedAt && !Number(order.signedPackages || 0)
&& !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed')
evidence.deliveries.push(order)
}
@@ -409,6 +467,74 @@ export async function readReviewedVehicleOccupation(env, vehicleId, fetcher = gl
return result.rows[0]
}
export async function readRecoveryTripResources(env, tripNo, fetcher = globalThis.fetch) {
validateEvidenceConfig(env)
if (!/^[A-Za-z0-9_-]{1,128}$/.test(tripNo)) throw Error('Exact recovery trip number required')
const sql = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;',
'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;',
`SELECT JSON_OBJECT('resourceKind',resource_kind,'resourceId',CAST(resource_id AS CHAR),'tripNo',trip_no) FROM ll_fleet_resource_assignment WHERE trip_no='${tripNo}' ORDER BY resource_kind;`, 'COMMIT;'].join('\n')
const script = buildEvidenceRemoteScript('lastleg', sql, env.FBA_CORE_JENKINS_AGENT)
const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST',
headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) })
if (!response.ok) throw Error('Recovery resources readonly HTTP failure')
const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON='))
if (lines.length !== 1) throw Error('Recovery resource evidence ambiguous')
const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length))
if (result.ok !== true || !Array.isArray(result.rows)) throw Error('Recovery resource query failed')
return result.rows
}
export async function verifyReturnSourceRecovery(context, { verifyOriginal = verifyDepartedRecovery } = {}) {
const original = await verifyOriginal(context, { costsAlreadyVerified: true, returnSourceReady: true })
const run = context.createdResources.fleetAcceptance, rows = []
const previous = original.trip.returnRedispatch
for (const delivery of previous.originalDeliveries) {
const response = await context.request('GET', `/api/platform/v1/last-leg/delivery-orders/${delivery.deliveryNo}/return-inbound-options?customerId=${run.customerId}`)
if (!success(response) || !response.requestId || !response.traceId || !Array.isArray(response.body.data)) throw Error('Repaired return candidates must be definitive')
for (const relation of response.body.data) {
const tail = await context.request('GET', `/api/platform/v1/last-leg/orders/${relation.lastMileOrderId}?customerId=${run.customerId}`)
if (!success(tail) || !tail.requestId || !tail.traceId || relation.deliveryNo !== delivery.deliveryNo
|| relation.outboundNo !== delivery.outboundNo || id(tail.body.data?.order?.customerId) !== id(run.customerId)
|| id(tail.body.data.order.overseasWarehouseId) !== id(run.warehouseId)
|| id(tail.body.data.order.id) !== id(relation.lastMileOrderId)) throw Error('Repaired source owner or delivery identity mismatch')
rows.push({ relation, tail: tail.body.data.order, delivery })
}
}
const selected = selectReturnSources(rows)
context.createdResources.fleetDepartureRecoveryVerification.returnSourceSelection = selected
await context.checkpoint()
return original
}
export async function continueReviewedReturnSources(context, { verify = verifyReturnSourceRecovery,
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
redispatch = verifyFleetReturnRedispatch, closeOriginal = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
const verified = await verify(context), trip = verified.trip, run = context.createdResources.fleetAcceptance
trip.previousSourceSelectionAttempts = [trip.returnRedispatch]; delete trip.returnRedispatch
run.previousPicker = run.picker; delete run.picker
run.pickerMarker = `${context.marker}-RETURN-RECOVERY-${randomUUID().slice(0, 8)}`
await context.checkpoint()
let picker
try {
const child = Object.create(context); child.marker = run.pickerMarker
const access = await resolveAccess(context)
picker = access ? await makePicker(child, 71, run.warehouseId, access) : null
if (!picker?.token || !picker.membershipId) throw Error('Source recovery requires a fresh bound PDA session')
run.picker = { membershipId: picker.membershipId, username: picker.username }; await context.checkpoint()
const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
const result = await redispatch(context, MODULE, trip, { customerId: run.customerId, warehouseId: run.warehouseId,
pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo], sourceSelectionRecovery: true })
run.receipts = result.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt }))
context.context.saveFleetReturnRedispatchFixture(result)
run.closure = await closeOriginal(context, MODULE, trip)
if (run.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('Recovered original trip did not close')
await cleanup(context, MODULE, trip)
run.stage = 'FINISHED'; run.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; await context.checkpoint()
return run
} finally { await closePicker(context, picker, 71) }
}
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) {
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle })
const run = context.createdResources.fleetAcceptance
@@ -802,7 +928,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
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')
const bytes = readFileSync(previous)
if (config.resumeStage?.startsWith('DEPARTED_') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256)
if ((config.resumeStage?.startsWith('DEPARTED_') || config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256)
throw Error('Reviewed departure report SHA256 mismatch; no environment request sent')
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 })
@@ -826,6 +952,9 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
context.context.fleetRunId = config.runId
context.context.fleetScenario = config.scenario || 'handover'
context.context.readReviewedVehicleOccupation = vehicleId => readReviewedVehicleOccupation(env, vehicleId)
context.context.readRecoveryTripResources = tripNo => readRecoveryTripResources(env, tripNo)
context.context.onBeforeRedispatchSignature = proof => waitForRedispatchSignatureAudit(context, proof,
{ root, output, secrets: [...secrets, context.token, context.pdaToken, context.refreshToken] })
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture)
let handoverCheckpoint = 0
@@ -839,6 +968,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
}
context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance)
const fleetScenario = async (ctx, options = {}) => {
if (options.returnSourceSelection) return continueReviewedReturnSources(ctx)
if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors,
costsAlreadyVerified: options.costsAlreadyVerified, createdVehicle: options.createdVehicle })
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
@@ -867,6 +997,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || ''
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
resumeDeparted: resumed.departed,
returnSourceSelection: resumed.returnSourceSelection,
costsAlreadyVerified: resumed.costsAlreadyVerified,
createdVehicle: resumed.createdVehicle,
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
+85 -2
View File
@@ -50,6 +50,88 @@ function createdVehicleRecoveryFixture() {
return { ...f, snapshot, request }
}
function returnSourceRecoveryFixture() {
const f = afterCostsRecoveryFixture(), { report, trip, responses, base, ctx } = f, run = report.createdResources.fleetAcceptance
run.stage = 'RETURN_REDISPATCH'; run.receipts = []; run.departure.shippedPackages = 5
report.steps = [{ name: '模块执行', passed: false, evidence: 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' }]
const current = { ...responses.get(base).trip, recordVersion: '30', vehicleId: 'V2', driverId: 'D2', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' }
responses.get(base).trip = current
const original = trip.costAcceptance.departure
original.snapshot.confirmedLoad.cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'IA', warehousePalletId: 'PA', packages: 3 },
{ dispatchNo: 'OWN', dispatchItemId: 'IB', warehousePalletId: 'PB', packages: 1 }, { dispatchNo: 'EXT', dispatchItemId: 'IX', warehousePalletId: 'PX', packages: 1 }]
const history = { records: [structuredClone(original), { action: 'HANDOVER', recordVersion: '28' },
{ action: 'RELEASE', recordVersion: '29' }, { action: 'RELEASE', recordVersion: '30' }], total: 4 }
responses.set(base + '/history?page=1&size=100', history)
const handover = { records: [{ handoverNo: 'HO-CURRENT', recordVersion: '2' }], total: 1 }
const vehicle = { id: 'V2', recordVersion: '0', plateNumber: 'NEW', status: 'IN_SERVICE' }
const driver = { id: 'D2', recordVersion: '2', active: true, reviewStatus: 'APPROVED', licenseFileId: 'FILE', details: {} }
trip.handover = { status: 'VERIFIED', finalTrip: structuredClone(current), handoverNo: 'HO-CURRENT', requests: [
{ method: 'GET', path: base + '/history?page=1&size=100', result: structuredClone(history) },
{ method: 'GET', path: base + '/handovers?page=1&size=10', result: structuredClone(handover) },
{ method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', result: structuredClone(vehicle) },
{ method: 'POST', path: '/api/platform/v1/last-leg/fleet/drivers/D2/review', result: structuredClone(driver) }] }
responses.set(base + '/handovers', handover); responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2', vehicle)
responses.set('/api/platform/v1/last-leg/fleet/drivers/D2', driver)
ctx.context.readRecoveryTripResources = async () => [{ resourceKind: 'VEHICLE', resourceId: 'V2', tripNo: 'TR-OWN' }, { resourceKind: 'DRIVER', resourceId: 'D2', tripNo: 'TR-OWN' }]
const rows = [['A', 3, 'OWN'], ['B', 1, 'OWN'], ['EXT', 1, 'EXT']]
const originals = rows.map(([label, packages, dispatchNo]) => ({ deliveryNo: 'DL-' + label, outboundNo: 'OUT-' + label, dispatchNo,
customerId: 'C1', overseasWarehouseId: 'W1', status: 'WAIT_DELIVERY', version: '2', packageCount: packages,
documentSummary: { categories: [{ fileCount: 0 }] } }))
trip.returnRedispatch = { marker: 'RUN', status: 'FAILED', failure: report.steps[0].evidence, originalDeliveries: originals,
requests: originals.map(order => ({ method: 'PUT', path: `/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/status?customerId=C1`,
body: { status: 'DELIVERING', expectedVersion: 2 }, status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't',
result: { deliveryNo: order.deliveryNo, deliveryStatus: 'DELIVERING', replayed: false } })) }
report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos = ['DL-A', 'DL-B']
for (const [label, packages, dispatchNo] of rows) {
const order = originals.find(row => row.deliveryNo === 'DL-' + label)
responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`, { order: { ...structuredClone(order),
status: 'DELIVERING', version: '3', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } })
const relation = { lastMileOrderId: label, inboundCode: 'IN-' + label, deliveryNo: order.deliveryNo, outboundNo: order.outboundNo,
returnSources: [{ maxReturnPackages: packages }] }
responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`, [relation])
responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`, { order: { id: label, customerId: 'C1', overseasWarehouseId: 'W1',
orderType: label === 'EXT' ? 'WAREHOUSE_TRANSFER' : 'FIRST_LEG', sourceInboundId: 'SOURCE-' + label, inboundCode: relation.inboundCode, packageCount: packages } })
}
for (const no of ['OWN', 'EXT']) {
const cargo = original.snapshot.confirmedLoad.cargo.filter(row => row.dispatchNo === no), total = cargo.reduce((n,row)=>n+row.packages,0)
responses.set('/api/platform/v1/last-leg/dispatches/' + no, { dispatchNo: no, status: 'DEPARTED', overseasWarehouseId: 'W1',
packageCount: total, pickedPackageCount: total, shippedPackageCount: total,
items: cargo.map(row=>({id:row.dispatchItemId,warehousePalletId:row.warehousePalletId,packageCount:row.packages})) })
}
return f
}
test('source-selection recovery only accepts original three explicit starts and rejects any actual return or unsigned result', () => {
const f = returnSourceRecoveryFixture()
assert.equal(reviewedFullCheckpoint(f.report, 'RETURN_SOURCE_SELECTION_BEFORE_RETURN').returnSourceSelection, true)
for (const change of [x=>x.trip.returnRedispatch.requests.push({ method:'POST',path:'/mark-return' }),
x=>x.trip.returnRedispatch.requests[0].status='UNKNOWN_RESULT',x=>x.trip.returnRedispatch.requests[0].result.replayed=true]) {
const mutated = returnSourceRecoveryFixture(); change(mutated)
assert.throws(()=>reviewedFullCheckpoint(mutated.report,'RETURN_SOURCE_SELECTION_BEFORE_RETURN'), /source recovery/)
}
})
for (const fault of ['none','candidate-empty','delivery-waiting','resource-wrong','cost-changed','handover-changed'])
test(`source recovery validates current business facts and repaired candidates before PDA write: ${fault}`,async()=>{
const f=returnSourceRecoveryFixture()
if(fault==='candidate-empty')f.responses.get('/api/platform/v1/last-leg/delivery-orders/DL-A/return-inbound-options?customerId=C1')[0].returnSources=[]
if(fault==='delivery-waiting')f.responses.get('/api/platform/v1/last-leg/delivery-orders/DL-A?customerId=C1').order.status='WAIT_DELIVERY'
if(fault==='resource-wrong')f.ctx.context.readRecoveryTripResources=async()=>[]
if(fault==='cost-changed')f.responses.get(f.base+'/costs?page=1&size=100').summary.totals[0].directCost='99.00'
if(fault==='handover-changed')f.responses.get(f.base+'/handovers').records[0].recordVersion='3'
if(fault==='none'){await verifyReturnSourceRecovery(f.ctx);assert.equal(f.ctx.createdResources.fleetDepartureRecoveryVerification.returnSourceSelection.a.tail.packageCount,3)}
else await assert.rejects(continueReviewedReturnSources(f.ctx,{makePicker:()=>assert.fail('no PDA write before verification')}))
})
test('source recovery preserves original costs/handover and invokes only actual return continuation and closure',async()=>{
const f=returnSourceRecoveryFixture(), operations=[], costs=f.trip.costAcceptance,hand=f.trip.handover
f.ctx.context.saveFleetReturnRedispatchFixture=()=>operations.push('save-return-proof')
await continueReviewedReturnSources(f.ctx,{resolveAccess:async()=>({}),makePicker:async()=>({token:'new-pda',membershipId:'NEW'}),closePicker:async()=>operations.push('close-pda'),
redispatch:async(_ctx,_module,trip,input)=>{assert.equal(input.sourceSelectionRecovery,true);assert.equal(trip.costAcceptance,costs);assert.equal(trip.handover,hand);operations.push('return-candidates');return {originalSignatures:[{deliveryNo:'DL-A',signedAt:'signed'}]}},
closeOriginal:async()=>{operations.push('close-original');return {status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},cleanup:async()=>operations.push('cleanup')})
assert.deepEqual(operations,['return-candidates','save-return-proof','close-original','cleanup','close-pda'])
})
test('created-vehicle checkpoint requires exact successful uppercase response and no subsequent writes', () => {
const { report } = createdVehicleRecoveryFixture()
const accepted = reviewedFullCheckpoint(report, 'DEPARTED_VEHICLE_CREATED')
@@ -201,7 +283,7 @@ test('changed source report SHA blocks recovery before creating context or loggi
rmSync(temporary, { recursive: true, force: true })
}
})
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation } from './fleet-test-env-acceptance.mjs'
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs'
for (const response of [
{ http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' },
@@ -500,7 +582,8 @@ test('phone lookup failure stops without any user creation or guessing', async (
})
test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => {
const source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8')
const fullSource = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8')
const source = fullSource.slice(fullSource.indexOf('export async function runFleetScenario'))
const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')")
const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip')
const cleanup = source.indexOf('await cleanup(context, MODULE, trip)')