fix(fleet): reuse reviewed handover vehicle and check formal responses
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user