diff --git a/scripts/README.md b/scripts/README.md index 0fdaff748..f5c0d7541 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -38,6 +38,10 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 +该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,仅接受全新批次。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 + +经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。 + `order-submit-dependency-fault-executor.mjs` 是显式 TEST 下单依赖故障入口,内部使用 `order-submit-dependency-fault-proxy.py`、`order-submit-dependency-fault-runtime.py`。`plan --plan <文件>` 只读校验;`preflight --plan <文件> --routing <文件> --confirm-test-runtime yes` 核对测试 Order 镜像、实际依赖和 Nacos v3 配置(固定v3登录与Admin配置读取、校验namespaceId/groupName/dataId后仅记录内容摘要,不回退v1);`run` 另需 `--expected-image <镜像ID>`、全新 `--report <文件>`。凭据仅从明确指定的 TEST 与 Jenkins 环境文件读取,路由文件不得包含凭据。必须事先准备本轮两个独立合法草稿、原始版本与幂等键及完整快照;仅对本轮客户的 IAM 资格和 Finance 钱包 GET 注入短时故障,验证两端提交失败后事实不变。运行会临时重建同镜像 Order 容器并在恢复后真实提交草稿,因此不得与部署或其他共享环境造数并行;不写业务 SQL。结束先恢复直通、原镜像配置、健康与依赖读取,再停止代理;失败保留报告和受限恢复文件,不自动重跑或删除数据。结果未知时使用 `status`、`reconcile` 核对原操作,必要时显式 `restore --plan <文件> --report <新文件> --confirm-test-runtime yes`,不得因观察超时重复创建容器。离线合同为对应 proxy/executor `.test.mjs`,离线通过不代表真实故障注入已验收。 `fba-core-regression-test-env.mjs` 为显式 TEST 的 F40 三模式串行 HTTP 专项,默认 `--plan` 只显示计划;`--execute --environment TEST --run-id <新批次>` 执行根先签、子先签、根签收后余量取消。`--tax-mode` 只接受 `BUY_DECLARATION` 或 `TAX_REFUND`,省略时保持买单默认语义。显式 `--tax-mode TAX_REFUND` 将税制传给现有 `context.f40TaxMode`,三种签收顺序均复用正式 F40 依赖编排及退税内部模块,走资料上传、回执确认、正式申报与人工放行、完成文件、尾程签收,并在最终 `COMPLETED` 后验证客户报关资料读取和文件下载;不复制报关流程。计划、manifest、汇总、各模式报告和只读取证日志均标明税制及范围。会通过正式 API 创建客户、单据、文件并使用共享 PDA/托盘,临时 PDA 按原模块 finally 停用。`fba-core-regression-evidence.mjs` 是内部只读取证 helper,Jenkins scriptText 仅在明确 TEST 节点对 TEST 容器所用数据库执行一致性只读 SELECT,不触发部署、不写业务 SQL。凭据通过显式 `node --env-file="" --env-file=""` 提供,数据库凭据只在执行节点内存读取。 diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs index 7d32e6a45..60e866482 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -12,8 +12,9 @@ function fixture(fault = '') { const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT', recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', - status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ inboundCode: `IN-${customer}`, packageCount: 10 }] })) - const liveDispatches = dispatchDetails.map(row => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) + status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, + items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] })) + const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 })) const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' })) const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } } @@ -24,6 +25,8 @@ function fixture(fault = '') { const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) } const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId })) + if (fault === 'binding-customer') bindings[0].customerId = 'OTHER' + if (fault === 'dispatch-cargo') liveDispatches[0].items = [{ ...liveDispatches[0].items[0], id: 'OTHER-ITEM' }] const context = { context: { async checkpointFleetHandover() {} }, marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, @@ -207,7 +210,7 @@ test('every HTTP call observes a completed pre-send checkpoint; post-response di assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING') }) -for (const [fault, message] of Object.entries({ 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', +for (const [fault, message] of Object.entries({ 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs index 639c0d01b..b9817746c 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -61,7 +61,7 @@ function io(context, module, proof) { return { check, request } } function dispatchFacts(row) { - const fields = ['dispatchNo', 'customerId', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', + const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) } @@ -113,9 +113,13 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke for (const expected of dispatchDetails) { const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) + const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo) + const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId), + packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId)) check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED' && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) - && text(row.customerId) === text(binding.customerId) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) + && binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId)) + && same(items(row.items || []), items(cargo)) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) proof.dispatchBefore.push(dispatchFacts(row)) const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 13e8040da..86068aa70 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -154,8 +154,8 @@ 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'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') - if (config.resumeStage === 'DEPARTED_BEFORE_COSTS' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) + 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 (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') if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport)) @@ -168,9 +168,13 @@ export function fleetExecutionPlan(config) { 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 === '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', + stages: config.resumeStage?.startsWith('DEPARTED_') + ? ['Verify reviewed report SHA256', config.resumeStage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' + ? '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', + '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', 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', @@ -183,19 +187,26 @@ 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') { + if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER') { + const afterCosts = stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false) - if (report?.success !== false || proof?.stage !== 'TRIP_OPERATING_COSTS' || proof.status !== 'CHECKING' + const failureName = afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘' + const handover = trip?.handover + 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 - || 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 + || costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length + || 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') + || !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 === '独立预期覆盖全部实物且没有重叠托盘') - || failures.some(row => !['独立预期覆盖全部实物且没有重叠托盘', '模块执行'].includes(row.name))) + || 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 } + return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts } } if (stage === 'TERMINAL_READONLY') { const failures = report?.steps?.filter(row => row.passed === false) @@ -298,7 +309,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) { +export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false } = {}) { 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 @@ -324,7 +335,8 @@ export async function verifyDepartedRecovery(context) { && 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 + check((costsAlreadyVerified ? same(costs, trip.costAcceptance.final) + : 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()) @@ -638,6 +650,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol } export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, + costsAlreadyVerified = false, verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId @@ -646,8 +659,10 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa 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 verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + if (!costsAlreadyVerified) { + stage('TRIP_OPERATING_COSTS') + await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + } else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED') stage('IN_TRANSIT_HANDOVER') await handover(context, trip, picker.token, dispatchDetails, { onReady: onHandoverReady ? async fixture => { @@ -681,31 +696,39 @@ export async function completeDepartedFleetScenario(context, trip, picker, dispa } export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, + costsAlreadyVerified = false, resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, complete = completeDepartedFleetScenario } = {}) { - const verified = await verify(context) + const verified = await verify(context, { costsAlreadyVerified }) const proof = context.createdResources.fleetAcceptance, trip = verified.trip - context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors - trip.previousReadOnlyCostAttempts = [trip.costAcceptance] + if (!costsAlreadyVerified) { + context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors + trip.previousReadOnlyCostAttempts = [trip.costAcceptance] + delete context.createdResources.fleetCostActors + delete trip.costAcceptance + } else { + trip.previousReadOnlyHandoverAttempts = [trip.handover] + delete trip.handover + } 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 + if (!costsAlreadyVerified) { + 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') + 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 }) + return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified }) } finally { try { await closePicker(context, picker, 71) } finally { await actors?.close() } } @@ -738,7 +761,7 @@ 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) + if (config.resumeStage?.startsWith('DEPARTED_') && 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 }) @@ -774,7 +797,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 }) + if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, costsAlreadyVerified: options.costsAlreadyVerified }) const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) try { return await scenario(ctx, { ...options, costActors }) } finally { await costActors?.close() } @@ -801,6 +824,7 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, resumeDeparted: resumed.departed, + costsAlreadyVerified: resumed.costsAlreadyVerified, 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 fc46aeb5f..0808a4ac8 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -15,7 +15,7 @@ function departedRecoveryFixture() { 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' }] }, + requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't' }] }, 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'] } }] } } @@ -38,6 +38,46 @@ function departedRecoveryFixture() { return { report, ctx, calls, responses, base, trip } } +function afterCostsRecoveryFixture() { + const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture + report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER' + report.steps[0].name = '每张排车已发车且货量与客户起运仓一致' + trip.costAcceptance.status = 'VERIFIED' + trip.costAcceptance.costs = [{ costNo: 'CNY-COST' }, { costNo: 'USD-COST' }] + trip.costAcceptance.final = { page: { records: structuredClone(trip.costAcceptance.costs), total: 2 }, + summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [{ currency: 'CNY', directCost: '100.03' }, { currency: 'USD', directCost: '13.07' }], allocationBlockReason: 'MULTI_STOP' } } + trip.handover = { status: 'FAILED', requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] } + responses.set(base + '/costs?page=1&size=100', structuredClone(trip.costAcceptance.final)) + return fixture +} + +test('after-cost recovery requires its exact known read-only handover failure and rejects any handover write', () => { + const { report } = afterCostsRecoveryFixture() + assert.equal(reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER').costsAlreadyVerified, true) + report.createdResources.fleetTrips[0].handover.requests.push({ method: 'POST', status: 'RESPONDED', http: 200, code: '200' }) + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/) +}) + +test('after-cost recovery checks original cost contents and refuses a changed amount before any write', async () => { + const { ctx, responses, base } = afterCostsRecoveryFixture() + responses.get(base + '/costs?page=1&size=100').summary.totals[0].directCost = '100.04' + await assert.rejects(continueReviewedDeparture(ctx, { costsAlreadyVerified: true, + prepareActors: () => assert.fail('no new actors'), makePicker: () => assert.fail('no new PDA before verification') }), /costs already written or cargo basis changed/) +}) + +test('after-cost recovery retains verified costs and only enters handover, signatures and resource closure', async () => { + const { ctx, trip } = afterCostsRecoveryFixture(), costs = trip.costAcceptance, oldHandover = trip.handover, operations = [] + await continueReviewedDeparture(ctx, { costsAlreadyVerified: true, prepareActors: () => assert.fail('verified costs need no new cost actors'), + resolveAccess: async () => ({}), makePicker: async () => ({ token: 'new-pda', membershipId: 'NEW-PDA' }), closePicker: async () => operations.push('close-pda'), + complete: (context, trip, picker, details, options) => completeDepartedFleetScenario(context, trip, picker, details, { ...options, + verifyCosts: () => assert.fail('costs must never be recreated'), handover: async () => operations.push('handover'), + completeDelivery: async (_ctx, order) => { operations.push('sign:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } }, + closeTrip: async () => { operations.push('release'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, cleanup: async () => operations.push('cleanup'), + }) }) + assert.equal(trip.costAcceptance, costs); assert.equal(trip.previousReadOnlyHandoverAttempts[0], oldHandover) + assert.deepEqual(operations, ['handover', 'sign:DL-OWN', 'sign:DL-EXT', 'release', 'cleanup', 'close-pda']) +}) + 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)]