test(fleet): resume reviewed post-departure acceptance safely

This commit is contained in:
2026-10-03 20:06:02 +08:00
parent bab967acce
commit 6d922516f6
2 changed files with 256 additions and 15 deletions
+142 -11
View File
@@ -140,7 +140,7 @@ export function parseFleetArguments(args) {
if (action) throw Error('Choose exactly one plan or execute action') if (action) throw Error('Choose exactly one plan or execute action')
action = name; config.execute = name === '--execute'; continue action = name; config.execute = name === '--execute'; continue
} }
if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--resume-sha256', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument')
if (seen.has(name)) throw Error('Duplicate argument') if (seen.has(name)) throw Error('Duplicate argument')
seen.add(name) seen.add(name)
const value = args[++index] const value = args[++index]
@@ -148,12 +148,15 @@ export function parseFleetArguments(args) {
else if (name === '--run-id') config.runId = value else if (name === '--run-id') config.runId = value
else if (name === '--output') config.outputDir = value else if (name === '--output') config.outputDir = value
else if (name === '--resume-report') config.resumeReport = value else if (name === '--resume-report') config.resumeReport = value
else if (name === '--resume-sha256') config.resumeSha256 = value
else if (name === '--scenario') config.scenario = value else if (name === '--scenario') config.scenario = value
else config.resumeStage = value else config.resumeStage = value
} }
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'].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'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[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 (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))
throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint') throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint')
@@ -163,9 +166,13 @@ export function fleetExecutionPlan(config) {
return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover', return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover',
returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full', returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full',
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 read-only FULL_COVERAGE 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, resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null,
stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', 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',
'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', 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking',
'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', 'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint',
'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'], 'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'],
@@ -176,6 +183,20 @@ 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') {
const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false)
if (report?.success !== false || proof?.stage !== '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
|| resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId
|| failures?.length !== 2 || !failures.some(row => row.name === '独立预期覆盖全部实物且没有重叠托盘')
|| failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].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 }
}
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)
if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE' if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE'
@@ -276,6 +297,66 @@ export async function verifyFleetTerminalState(context) {
return proof return proof
} }
/** All business checks happen before creating replacement test sessions or issuing a business command. */
export async function verifyDepartedRecovery(context) {
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 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
const check = (ok, message) => { if (!ok) throw Error(`Departed recovery refused: ${message}`) }
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
async function get(path) {
const r = await context.request('GET', path)
evidence.requests.push({ path, http: r.http, code: r.body?.code, requestId: r.requestId, traceId: r.traceId })
check(success(r) && r.requestId && r.traceId, 'read-only response not definitive')
return r.body.data
}
const current = await get(base)
check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(snapshot.trip.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)
&& !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')
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
&& 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())
&& current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed')
for (const number of proof.dispatchNos) {
const detail = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`)
const cargo = load.cargo.filter(row => row.dispatchNo === number)
const facts = rows => rows.map(row => [id(row.id ?? row.dispatchItemId), id(row.warehousePalletId), Number(row.packageCount ?? row.packages)])
.sort((a, b) => a[0].localeCompare(b[0]))
check(detail.dispatchNo === number && detail.status === 'DEPARTED' && id(detail.overseasWarehouseId) === id(proof.warehouseId)
&& Number(detail.packageCount) === Number(detail.shippedPackageCount) && Number(detail.pickedPackageCount) === Number(detail.shippedPackageCount)
&& same(facts(detail.items), facts(cargo)), 'dispatch shipped cargo changed')
evidence.dispatchDetails.push(detail)
}
const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
const deliveryNos = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo]
check(new Set(deliveryNos).size === deliveryNos.length, 'duplicate delivery identities')
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
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.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed')
evidence.deliveries.push(order)
}
check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch')
Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash,
departure: departures[0], costSummary: costs.summary })
await context.checkpoint()
return { trip, dispatchDetails: evidence.dispatchDetails }
}
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) { export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) {
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails) const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails)
const run = context.createdResources.fleetAcceptance const run = context.createdResources.fleetAcceptance
@@ -287,6 +368,8 @@ export async function runFleetHandoverStage(context, trip, pickerToken, dispatch
departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo, departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo,
originalResources: state.previous, replacementResources: state.replacement, browser: state.browser, originalResources: state.previous, replacementResources: state.replacement, browser: state.browser,
costs: trip.costAcceptance, costs: trip.costAcceptance,
allocationStatus: trip.costAcceptance.expected?.allocationStatus,
allocationBlockReason: trip.costAcceptance.expected?.allocationBlockReason,
status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount } status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount }
context.context?.saveFleetHandoverFixture?.(fixture) context.context?.saveFleetHandoverFixture?.(fixture)
if (onReady) await onReady(fixture) if (onReady) await onReady(fixture)
@@ -532,10 +615,24 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token)
check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages))
proof.departure = departed proof.departure = departed
await completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady })
} finally { await disablePicker(context, picker, 71) }
return proof
}
export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady,
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId
const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL')
const external = trip.externalDispatch, dispatchNos = proof.dispatchNos
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') stage('TRIP_OPERATING_COSTS')
await verifyFleetTripCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
stage('IN_TRANSIT_HANDOVER') stage('IN_TRANSIT_HANDOVER')
await runFleetHandoverStage(context, trip, picker.token, dispatchDetails, { await handover(context, trip, picker.token, dispatchDetails, {
onReady: onHandoverReady ? async fixture => { onReady: onHandoverReady ? async fixture => {
stage('PDA_UI_CHECKPOINT') stage('PDA_UI_CHECKPOINT')
await onHandoverReady({ fixture, picker }) await onHandoverReady({ fixture, picker })
@@ -553,20 +650,50 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) {
const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`,
undefined, '签收前读取本次实际派送') undefined, '签收前读取本次实际派送')
const completed = await saveReceiptAndComplete(context, delivery.order, customerId, index + 71) const completed = await completeDelivery(context, delivery.order, customerId, index + 71)
check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt)
proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt })
} }
} }
proof.closure = await finishFleetTrip(context, MODULE, trip) proof.closure = await closeTrip(context, MODULE, trip)
check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
stage('HANDOVER_RESOURCE_CLEANUP') stage('HANDOVER_RESOURCE_CLEANUP')
await cleanupFleetHandoverResources(context, MODULE, trip) await cleanup(context, MODULE, trip)
proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED')
} finally { await disablePicker(context, picker, 71) }
return proof return proof
} }
export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors,
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
complete = completeDepartedFleetScenario } = {}) {
const verified = await verify(context)
const proof = context.createdResources.fleetAcceptance, trip = verified.trip
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
trip.previousReadOnlyCostAttempts = [trip.costAcceptance]
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
|| actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token))
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 })
} finally {
try { await closePicker(context, picker, 71) } finally { await actors?.close() }
}
}
export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) { export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) {
if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test' if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test'
|| basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required') || basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required')
@@ -594,6 +721,8 @@ 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)
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 })
} else { } else {
@@ -628,6 +757,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 })
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() }
@@ -653,6 +783,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed') if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed')
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,
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,
+114 -4
View File
@@ -1,12 +1,122 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs' import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join, dirname, resolve } from 'node:path' import { join, dirname, resolve } from 'node:path'
import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { AcceptanceRun } from './admin-test-env-acceptance.mjs'
import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs' import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs'
import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs' import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs'
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite } from './fleet-test-env-acceptance.mjs'
function departedRecoveryFixture() {
const snapshotTrip = { tripNo: 'TR-OWN', status: 'IN_TRANSIT', recordVersion: '27', vehicleId: 'V1', driverId: 'D1', warehouseId: 'W1', departedAt: '2026-10-03T11:00:00Z' }
const cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'I1', warehousePalletId: 'P1', packages: 2 }, { dispatchNo: 'EXT', dispatchItemId: 'I2', warehousePalletId: 'P2', packages: 1 }]
const departure = { action: 'DEPART', recordVersion: '27', snapshot: { trip: snapshotTrip, confirmedLoad: { manifestHash: 'a'.repeat(64), cargo } } }
const proof = { marker: 'RUN', tripNo: 'TR-OWN', warehouseId: 'W1', customerId: 'C1', status: 'CHECKING', stage: 'TRIP_OPERATING_COSTS', checkpoints: [],
dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' },
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,
requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] },
externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } }
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'] } }] } }
const base = '/api/platform/v1/last-leg/fleet/trips/TR-OWN'
const responses = new Map([[base, { trip: structuredClone(snapshotTrip), dispatches: [{ dispatchNo: 'OWN', customerId: 'C1' }, { dispatchNo: 'EXT', customerId: 'C1' }] }],
[base + '/history?page=1&size=100', { total: 1, records: [structuredClone(departure)] }],
[base + '/costs?page=1&size=100', { page: { records: [], total: 0 }, summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [] } }],
[base + '/handovers', { records: [], total: 0 }]])
for (const row of cargo) {
responses.set('/api/platform/v1/last-leg/dispatches/' + row.dispatchNo, { dispatchNo: row.dispatchNo, status: 'DEPARTED', overseasWarehouseId: 'W1',
packageCount: row.packages, pickedPackageCount: row.packages, shippedPackageCount: row.packages,
items: [{ id: row.dispatchItemId, warehousePalletId: row.warehousePalletId, packageCount: row.packages }] })
responses.set('/api/platform/v1/last-leg/delivery-orders/DL-' + row.dispatchNo + '?customerId=C1', { order: { deliveryNo: 'DL-' + row.dispatchNo,
customerId: 'C1', dispatchNo: row.dispatchNo, status: 'WAIT_DELIVERY', version: '1', packageCount: row.packages,
documentSummary: { categories: [{ fileCount: 0 }] } } })
}
const calls = [], ctx = { marker: 'RUN', token: 'writer', context: {}, createdResources: report.createdResources, async checkpoint() { calls.push('checkpoint') },
async request(method, path) { assert.equal(method, 'GET', 'recovery verification must never write'); calls.push(path); assert.ok(responses.has(path), path)
return { http: 200, body: { code: '200', data: responses.get(path) }, requestId: 'r', traceId: 't' } } }
return { report, ctx, calls, responses, base, trip }
}
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/)
const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)]
assert.equal(parseFleetArguments(args).resumeSha256, 'a'.repeat(64))
assert.throws(() => parseFleetArguments([...args, '--scenario', 'return-redispatch']), /fresh run/)
assert.equal(reviewedFullCheckpoint(departedRecoveryFixture().report, 'DEPARTED_BEFORE_COSTS').departed, true)
for (const change of [r => r.createdResources.fleetTrips[0].costAcceptance.requests.push({ method: 'POST' }),
r => r.createdResources.fleetTrips[0].handover = {}, r => r.createdResources.fleetAcceptance.receipts = [],
r => r.createdResources.fleetCostActors.status = 'READY', r => r.steps[0].name = 'unknown write']) {
const { report } = departedRecoveryFixture(); change(report)
assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_BEFORE_COSTS'), /checkpoint/)
}
})
for (const mutation of ['none', 'version', 'history', 'cost', 'handover', 'cargo', 'signature', 'document'])
test(`recovery rechecks actual state before any session/business write: ${mutation}`, async () => {
const { ctx, responses, base } = departedRecoveryFixture()
if (mutation === 'version') responses.get(base).trip.recordVersion = '28'
if (mutation === 'history') responses.get(base + '/history?page=1&size=100').records[0].snapshot.confirmedLoad.manifestHash = 'changed'
if (mutation === 'cost') responses.get(base + '/costs?page=1&size=100').page.records.push({ costNo: 'COST' })
if (mutation === 'handover') responses.get(base + '/handovers').records.push({ handoverNo: 'H' })
if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN').items[0].packageCount = 3
const delivery = responses.get('/api/platform/v1/last-leg/delivery-orders/DL-OWN?customerId=C1').order
if (mutation === 'signature') delivery.signedAt = 'now'
if (mutation === 'document') delivery.documentSummary.categories[0].fileCount = 1
if (mutation === 'none') { await verifyDepartedRecovery(ctx); assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED') }
else await assert.rejects(continueReviewedDeparture(ctx, { prepareActors: () => assert.fail('no new actors before validation') }), /recovery refused/)
})
test('reviewed recovery archives old evidence and creates fresh sessions before shared post-departure flow only', async () => {
const { ctx, trip, calls } = departedRecoveryFixture(); const oldCost = trip.costAcceptance
await continueReviewedDeparture(ctx, {
prepareActors: async () => { assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED'); calls.push('new-actors'); return { marker: 'RUN', listOnlyToken: 'reader', deniedToken: 'denied', async close() { calls.push('close-actors') } } },
resolveAccess: async () => ({}), makePicker: async child => { assert.match(child.marker, /RECOVERY-PDA/); calls.push('new-picker'); return { membershipId: 'NEW', token: 'new-pda' } },
closePicker: async () => calls.push('close-picker'),
complete: async (context, actualTrip, picker, dispatches) => {
assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 2)
assert.equal(trip.previousReadOnlyCostAttempts[0], oldCost); assert.equal(context.createdResources.fleetAcceptance.previousPicker.membershipId, 'OLD-PDA')
calls.push('continue-from-costs')
},
})
assert.deepEqual(calls.filter(x => !x.startsWith('/') && x !== 'checkpoint'), ['new-actors', 'new-picker', 'continue-from-costs', 'close-picker', 'close-actors'])
})
test('shared continuation starts at costs, retains unavailable allocation and never enters picking or DEPART', async () => {
const { ctx, trip, calls } = departedRecoveryFixture()
const verified = await verifyDepartedRecovery(ctx), operations = []
await completeDepartedFleetScenario(ctx, trip, { token: 'pda' }, verified.dispatchDetails, {
costActors: { marker: 'RUN' },
verifyCosts: async () => { operations.push('costs'); trip.costAcceptance = { status: 'VERIFIED', expected: { allocationStatus: 'UNAVAILABLE' } } },
handover: async () => { assert.equal(trip.costAcceptance.expected.allocationStatus, 'UNAVAILABLE'); operations.push('handover') },
completeDelivery: async (context, order) => { operations.push('signature:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } },
closeTrip: async () => { operations.push('close-trip'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } },
cleanup: async () => operations.push('cleanup'),
})
assert.deepEqual(operations, ['costs', 'handover', 'signature:DL-OWN', 'signature:DL-EXT', 'close-trip', 'cleanup'])
assert.equal(ctx.createdResources.fleetAcceptance.stage, 'FINISHED')
assert.ok(!calls.some(x => /\/depart$|\/pick|\/loading/.test(x)))
})
test('changed source report SHA blocks recovery before creating context or logging in', async () => {
const temporary = mkdtempSync(join(tmpdir(), 'fleet-recovery-sha-'))
const root = join(temporary, 'chongchong-global', 'baiyue-firstleg'); mkdirSync(join(root, 'reports', 'original'), { recursive: true })
writeFileSync(join(root, 'reports', 'original', 'report.json'), JSON.stringify(departedRecoveryFixture().report))
try {
const result = await runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'reviewed', resumeStage: 'DEPARTED_BEFORE_COSTS',
resumeReport: 'reports/original/report.json', resumeSha256: 'b'.repeat(64) }, { root,
env: { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid', CHONGCHONG_ADMIN_USERNAME: 'admin',
CHONGCHONG_ADMIN_PASSWORD: 'secret', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'secret' },
createContext: () => assert.fail('SHA mismatch must precede context/network'),
})
assert.equal(result.success, false); assert.match(result.fatal, /SHA256 mismatch/)
} finally {
assert.equal(dirname(resolve(temporary)), resolve(tmpdir()))
assert.ok(resolve(temporary).startsWith(join(resolve(tmpdir()), 'fleet-recovery-sha-')))
rmSync(temporary, { recursive: true, force: true })
}
})
import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario } from './fleet-test-env-acceptance.mjs'
for (const response of [ for (const response of [
{ http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' },
@@ -281,8 +391,8 @@ for (const failLogin of [false, true]) test(`cost actors create exact isolated r
test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', 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 source = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8')
const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") 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 finishFleetTrip') const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip')
const cleanup = source.indexOf('await cleanupFleetHandoverResources(context, MODULE, trip)') const cleanup = source.indexOf('await cleanup(context, MODULE, trip)')
assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup)
assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/)
}) })