test(fleet): resume reviewed post-departure acceptance safely
This commit is contained in:
@@ -140,7 +140,7 @@ export function parseFleetArguments(args) {
|
||||
if (action) throw Error('Choose exactly one plan or execute action')
|
||||
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')
|
||||
seen.add(name)
|
||||
const value = args[++index]
|
||||
@@ -148,12 +148,15 @@ export function parseFleetArguments(args) {
|
||||
else if (name === '--run-id') config.runId = value
|
||||
else if (name === '--output') config.outputDir = 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 config.resumeStage = value
|
||||
}
|
||||
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'].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 (!['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')
|
||||
@@ -163,9 +166,13 @@ export function fleetExecutionPlan(config) {
|
||||
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 read-only FULL_COVERAGE checkpoint in the same original fixture' : 'phase4-overseas-dispatch with its normal fresh prerequisites',
|
||||
resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null,
|
||||
stages: ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle',
|
||||
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',
|
||||
'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',
|
||||
'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'],
|
||||
@@ -176,6 +183,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') {
|
||||
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') {
|
||||
const failures = report?.steps?.filter(row => row.passed === false)
|
||||
if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE'
|
||||
@@ -276,6 +297,66 @@ export async function verifyFleetTerminalState(context) {
|
||||
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 } = {}) {
|
||||
const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails)
|
||||
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,
|
||||
originalResources: state.previous, replacementResources: state.replacement, browser: state.browser,
|
||||
costs: trip.costAcceptance,
|
||||
allocationStatus: trip.costAcceptance.expected?.allocationStatus,
|
||||
allocationBlockReason: trip.costAcceptance.expected?.allocationBlockReason,
|
||||
status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount }
|
||||
context.context?.saveFleetHandoverFixture?.(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)
|
||||
check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages))
|
||||
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')
|
||||
await verifyFleetTripCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
|
||||
await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) })
|
||||
stage('IN_TRANSIT_HANDOVER')
|
||||
await runFleetHandoverStage(context, trip, picker.token, dispatchDetails, {
|
||||
await handover(context, trip, picker.token, dispatchDetails, {
|
||||
onReady: onHandoverReady ? async fixture => {
|
||||
stage('PDA_UI_CHECKPOINT')
|
||||
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()) {
|
||||
const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`,
|
||||
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)
|
||||
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')
|
||||
stage('HANDOVER_RESOURCE_CLEANUP')
|
||||
await cleanupFleetHandoverResources(context, MODULE, trip)
|
||||
await cleanup(context, MODULE, trip)
|
||||
proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED')
|
||||
} finally { await disablePicker(context, picker, 71) }
|
||||
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 } = {}) {
|
||||
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')
|
||||
@@ -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)
|
||||
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)
|
||||
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 })
|
||||
} else {
|
||||
@@ -628,6 +757,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 })
|
||||
const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx)
|
||||
try { return await scenario(ctx, { ...options, costActors }) }
|
||||
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')
|
||||
context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || ''
|
||||
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 }))
|
||||
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