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
+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)
|| (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 (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[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'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
if (config.resumeStage?.startsWith('DEPARTED_') ? !/^[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))
@@ -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,
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 === 'DEPARTED_BEFORE_COSTS'
? ['Verify reviewed report SHA256', 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries',
'Create fresh bounded cost/PDA sessions', 'Original trip operating costs with actual allocation availability',
stages: config.resumeStage?.startsWith('DEPARTED_')
? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
? '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']
: ['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',
@@ -183,19 +187,26 @@ 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 === '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)
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.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash
|| costs.status !== 'FAILED' || !Array.isArray(costs.costs) || costs.costs.length || !costs.requests?.length
|| costs.requests.some(row => row.method !== 'GET' || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200')
|| trip.handover || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch
|| costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length
|| costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId
|| (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
|| failures?.length !== 2 || !failures.some(row => row.name === '独立预期覆盖全部实物且没有重叠托盘')
|| failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].includes(row.name)))
|| failures?.length !== 2 || !failures.some(row => row.name === failureName)
|| 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')
return { resources, marker: proof.marker, departed: true }
return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts }
}
if (stage === 'TERMINAL_READONLY') {
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. */
export async function verifyDepartedRecovery(context) {
export async function verifyDepartedRecovery(context, { costsAlreadyVerified = 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
@@ -324,7 +335,8 @@ export async function verifyDepartedRecovery(context) {
&& departures?.length === 1 && same(departures[0], original)
&& 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`)
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')
check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists')
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,
costsAlreadyVerified = false,
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
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 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 }
stage('TRIP_OPERATING_COSTS')
await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
if (!costsAlreadyVerified) {
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')
await handover(context, trip, picker.token, dispatchDetails, {
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,
costsAlreadyVerified = false,
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
complete = completeDepartedFleetScenario } = {}) {
const verified = await verify(context)
const verified = await verify(context, { costsAlreadyVerified })
const proof = context.createdResources.fleetAcceptance, trip = verified.trip
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
trip.previousReadOnlyCostAttempts = [trip.costAcceptance]
if (!costsAlreadyVerified) {
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
delete context.createdResources.fleetCostActors
delete trip.costAcceptance
delete proof.picker
proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}`
await context.checkpoint()
let actors, picker
try {
actors = await prepareActors(context)
if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken
if (!costsAlreadyVerified) {
actors = await prepareActors(context)
if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken
|| 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 access = await resolveAccess(context)
picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null
if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session')
proof.picker = { membershipId: picker.membershipId, username: picker.username }
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 {
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)
if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports')
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')
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 })
@@ -774,7 +797,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.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)
try { return await scenario(ctx, { ...options, costActors }) }
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 || ''
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
resumeDeparted: resumed.departed,
costsAlreadyVerified: resumed.costsAlreadyVerified,
resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal }))
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules,