fix(fleet): reuse reviewed handover vehicle and check formal responses

This commit is contained in:
2026-10-03 21:00:07 +08:00
parent 99a469e6cf
commit 18b39b4378
5 changed files with 226 additions and 48 deletions
+63 -19
View File
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto'
import { AsyncLocalStorage } from 'node:async_hooks'
import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs'
import { redactEvidence } from './fba-core-regression-test-env.mjs'
import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs'
import { createTestEvidenceReaders, buildEvidenceRemoteScript, validateEvidenceConfig } from './fba-core-regression-evidence.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs'
import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs'
@@ -154,7 +154,7 @@ 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'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required')
if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', '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)
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')
@@ -163,17 +163,19 @@ export function parseFleetArguments(args) {
return config
}
export function fleetExecutionPlan(config) {
const afterCosts = ['DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage)
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',
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_')
? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
? ['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',
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',
...(config.resumeStage === 'DEPARTED_VEHICLE_CREATED' ? ['Verify exact successful vehicle creation, complete current vehicle snapshot and zero resource assignments', 'Explicitly reuse the created vehicle; skip its creation POST and continue from new driver'] : []),
afterCosts ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions',
afterCosts ? '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',
@@ -187,11 +189,20 @@ 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' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER') {
const afterCosts = stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'
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'
const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false)
const failureName = afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘'
const failureName = vehicleCreated ? '接替车辆返回真实身份及车牌' : afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘'
const handover = trip?.handover
const creation = vehicleCreated ? handover?.requests?.filter(row => row.method !== 'GET') : null
if (vehicleCreated && (creation?.length !== 1 || creation[0].method !== 'POST'
|| creation[0].path !== '/api/platform/v1/last-leg/master-data/vehicles' || creation[0].status !== 'RESPONDED'
|| creation[0].http !== 200 || id(creation[0].code) !== '200' || !creation[0].requestId || !creation[0].traceId
|| !/^[1-9]\d*$/.test(id(creation[0].result?.id)) || id(creation[0].result.recordVersion) !== '0'
|| creation[0].result.plateNumber !== creation[0].body?.plateNumber?.toUpperCase()
|| creation[0].result.status !== 'IN_SERVICE' || handover.replacement?.driverId))
throw Error('Created vehicle recovery requires the exact definitive successful creation and no later handover write')
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
@@ -199,14 +210,15 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') {
|| 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')
|| handover.requests.some(row => (row.method !== 'GET' && row !== creation?.[0]) || 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 === 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, costsAlreadyVerified: afterCosts }
return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts,
createdVehicle: vehicleCreated ? { snapshot: creation[0].result, creationRequest: creation[0] } : null }
}
if (stage === 'TERMINAL_READONLY') {
const failures = report?.steps?.filter(row => row.passed === false)
@@ -309,7 +321,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, { costsAlreadyVerified = false } = {}) {
export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle } = {}) {
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
@@ -363,14 +375,42 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f
evidence.deliveries.push(order)
}
check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch')
if (createdVehicle) {
const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`)
const canonical = value => value && typeof value === 'object' && !Array.isArray(value)
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
check(same(canonical(vehicle), canonical(createdVehicle.snapshot)), 'created vehicle snapshot changed')
check(typeof context.context?.readReviewedVehicleOccupation === 'function', 'vehicle occupancy reader missing')
const occupation = await context.context.readReviewedVehicleOccupation(vehicle.id)
check(id(occupation?.vehicleId) === id(vehicle.id) && occupation.assignmentCount === 0, 'created vehicle is occupied')
evidence.reviewedVehicle = { ...createdVehicle, snapshot: vehicle, occupation }
}
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 }
return { trip, dispatchDetails: evidence.dispatchDetails, reviewedVehicle: evidence.reviewedVehicle }
}
export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady } = {}) {
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails)
export async function readReviewedVehicleOccupation(env, vehicleId, fetcher = globalThis.fetch) {
validateEvidenceConfig(env)
if (!/^[1-9]\d*$/.test(id(vehicleId))) throw Error('Exact numeric reviewed vehicle ID 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('vehicleId','${vehicleId}','assignmentCount',COUNT(*)) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=${vehicleId};`, '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('Reviewed vehicle occupancy HTTP failure')
const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON='))
if (lines.length !== 1) throw Error('Reviewed vehicle occupancy response ambiguous')
const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length))
if (result.ok !== true || result.rows?.length !== 1) throw Error('Reviewed vehicle occupancy query failed')
return result.rows[0]
}
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
if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo
|| run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser
@@ -650,7 +690,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol
}
export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady,
costsAlreadyVerified = false,
costsAlreadyVerified = false, reviewedVehicle,
verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete,
closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) {
const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId
@@ -665,6 +705,7 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa
} else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED')
stage('IN_TRANSIT_HANDOVER')
await handover(context, trip, picker.token, dispatchDetails, {
reviewedVehicle,
onReady: onHandoverReady ? async fixture => {
stage('PDA_UI_CHECKPOINT')
await onHandoverReady({ fixture, picker })
@@ -696,10 +737,10 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa
}
export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors,
costsAlreadyVerified = false,
costsAlreadyVerified = false, createdVehicle,
resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker,
complete = completeDepartedFleetScenario } = {}) {
const verified = await verify(context, { costsAlreadyVerified })
const verified = await verify(context, { costsAlreadyVerified, createdVehicle })
const proof = context.createdResources.fleetAcceptance, trip = verified.trip
if (!costsAlreadyVerified) {
context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors
@@ -728,7 +769,7 @@ export async function continueReviewedDeparture(context, { verify = verifyDepart
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, costsAlreadyVerified })
return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified, reviewedVehicle: verified.reviewedVehicle })
} finally {
try { await closePicker(context, picker, 71) } finally { await actors?.close() }
}
@@ -784,6 +825,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
}, { guardFetch: true })
context.context.fleetRunId = config.runId
context.context.fleetScenario = config.scenario || 'handover'
context.context.readReviewedVehicleOccupation = vehicleId => readReviewedVehicleOccupation(env, vehicleId)
context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture)
context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture)
let handoverCheckpoint = 0
@@ -797,7 +839,8 @@ 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, costsAlreadyVerified: options.costsAlreadyVerified })
if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors,
costsAlreadyVerified: options.costsAlreadyVerified, createdVehicle: options.createdVehicle })
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
try { return await scenario(ctx, { ...options, costActors }) }
finally { await costActors?.close() }
@@ -825,6 +868,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c
await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady,
resumeDeparted: resumed.departed,
costsAlreadyVerified: resumed.costsAlreadyVerified,
createdVehicle: resumed.createdVehicle,
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,