diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index ba63348d4..71cb3c3f5 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -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, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index e7cd4baf8..84ffe7ff7 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -1,12 +1,122 @@ import test from 'node:test' 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 { join, dirname, resolve } from 'node:path' import { AcceptanceRun } from './admin-test-env-acceptance.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 { 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 [ { 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 () => { 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 receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await finishFleetTrip') - const cleanup = source.indexOf('await cleanupFleetHandoverResources(context, MODULE, trip)') + const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip') + const cleanup = source.indexOf('await cleanup(context, MODULE, trip)') assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) })