diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs new file mode 100644 index 000000000..3c0ba662e --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -0,0 +1,164 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { selectReturnSources, verifyFleetReturnRedispatch } from './fleet-return-redispatch-acceptance.mjs' + +function fixture(fault = '') { + const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] + let returned = false, originalSigned = false, newSigned = false, departed = false + const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: name, inboundOrderId: String(index + 10), inboundCode: `IN-${name}`, + deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] })) + const tail = name => ({ orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, + subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' + : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) + const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null } + : { deliveryNo: 'DL-ORIGINAL', dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: 'DELIVERING', packageCount: 7, + signedPackages: originalSigned ? 5 : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : 'original-signature' : null } + const envelope = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request-id', traceId: 'trace-id' }) + const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: 'C', warehouseId: 'W', tripNo: trip.tripNo, departure: { status: 'IN_TRANSIT', dispatches: [{ dispatchNo: 'DP-ORIGINAL' }] } } }, + assert() {}, async checkpoint() { checkpoints.push(structuredClone(trip.returnRedispatch)) }, + async requestMultipart(method, path, file) { requests.push({ method, path, file: file.name }); return envelope({ fileId: '99' }) }, + async request(method, path, body, token) { + requests.push({ method, path, body: structuredClone(body), token }) + if (path.startsWith('/api/platform/inbounds?')) { + const code = new URLSearchParams(path.split('?')[1]).get('inboundCode'), name = code.slice(-1) + const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned + return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) + } + if (path.includes('/orders/')) return envelope({ order: tail(path.split('/orders/')[1].split('?')[0]) }) + if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) + if (path.includes('/mark-return')) return envelope({ exceptionNo: 'EX-NEW', version: 0 }) + if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 }) + if (path.includes('/exception-orders/') && path.includes('/complete')) { + const replayed = returned; returned = true + if (fault === 'unknown-return') throw Error('connection lost after commit') + return envelope({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', replayed }) + } + if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } + if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode }) + if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) + if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: 'A', packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) + if (path.endsWith('/drafts/containers')) return envelope({ id: '400' }) + if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) } + if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) } + if (path.endsWith('/fleet/trips/TR-NEW')) return envelope({ trip: { vehiclePlate: 'NEW', driverName: 'driver', driverPhone: 'phone' } }) + if (path.endsWith('/transfer-delivery')) return envelope({ deliveries: [{ deliveryNo: 'DL-NEW' }] }) + if (path.endsWith('/depart')) { assert.equal(token, 'private-pda-token'); const replayed = departed; departed = true; return envelope({ tripNo: 'TR-NEW', shippedPackages: 2, replayed }) } + if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/delivery-orders/')[1].split('?')[0]) }) + throw Error(`Unexpected ${method} ${path}`) + } } + const dependencies = { + async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true }, + async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, + async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, + } + const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } + return { context, trip, input, dependencies, requests, checkpoints } +} +const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) + +test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => { + const f = fixture(), result = await run(f) + assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') + assert.equal(result.sourceEventsVerified, false) + assert.equal(result.readOnlyEvidenceRequired.length, 3) + assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY') + assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') + assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED') + assert.equal(result.afterRedispatch.departed.shippedPackages, 2) + assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')) + assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256)) +}) +for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) { + test(`rejects ${fault} instead of declaring redispatch complete`, async () => { + const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') + if (fault === 'missing-B') assert.ok(f.requests.every(row => row.method === 'GET')) + }) +} +test('unknown return outcome persists the exact original key and cannot automatically restart', async () => { + const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/) + const last = f.checkpoints.at(-1).requests.at(-1) + assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) + const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) +test('pre-send checkpoint failure cannot send even the first HTTP request', async () => { + const f = fixture(); f.context.checkpoint = async () => { throw Error('disk full') } + await assert.rejects(run(f), /disk full/); assert.equal(f.requests.length, 0) +}) +test('fixture selection refuses partial coverage or borrowed source shipments', () => { + const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, + relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } }) + assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/) +}) + +test('WAIT_DELIVERY start submits exactly the version read from the current delivery', async () => { + const f = fixture(), original = f.context.request.bind(f.context) + let started = false + f.context.request = async (method, path, body, ...args) => { + if (method === 'PUT' && path.includes('/delivery-orders/DL-ORIGINAL/status')) { + assert.equal(body.expectedVersion, 17); assert.equal(body.status, 'DELIVERING'); started = true + f.requests.push({ method, path, body }) + return { http: 200, body: { code: '200', data: { deliveryStatus: 'DELIVERING' } }, requestId: 'r', traceId: 't' } + } + const response = await original(method, path, body, ...args) + if (!started && response.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') Object.assign(response.body.data.order, { status: 'WAIT_DELIVERY', version: '17' }) + if (path.includes('/return-inbound-options')) assert.equal(started, true) + return response + } + await run(f) + assert.equal(f.requests.filter(row => row.method === 'PUT' && row.path.includes('/status')).length, 1) +}) + +for (const multipart of [false, true]) test(`http:0 records UNKNOWN_RESULT and stops without retry; multipart=${multipart}`, async () => { + const f = fixture(), original = f.context.request.bind(f.context) + if (multipart) f.context.requestMultipart = async (method, path, file) => { + f.requests.push({ method, path, file: file.name }); return { http: 0, body: null, requestId: null, traceId: null } + } + else f.context.request = async (method, path, body, ...args) => { + if (path.includes('/exception-orders/') && path.includes('/complete')) { + f.requests.push({ method, path, body: structuredClone(body) }); return { http: 0, body: null, requestId: null, traceId: null } + } + return original(method, path, body, ...args) + } + await assert.rejects(run(f), /transport outcome unknown/) + const last = f.checkpoints.at(-1).requests.at(-1) + assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, 0) + if (multipart) assert.match(last.file.sha256, /^[a-f0-9]{64}$/) + else assert.ok(last.body.idempotencyKey) + assert.equal(f.requests.filter(row => row.path === last.path && row.method === last.method).length, 1) + const count = f.requests.length + await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) + +for (const [name, response] of Object.entries({ + server500: { http: 500, body: { code: 'B0001', message: 'Internal error' }, requestId: 'r', traceId: 't' }, + proxy502: { http: 502, body: null, requestId: null, traceId: null }, + malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, + htmlFailure: { http: 403, body: 'proxy blocked', requestId: 'r', traceId: 't' }, + uncorrelatedSuccess: { http: 200, body: { code: '200', data: { status: 'COMPLETED' } }, requestId: null, traceId: null }, +})) test(`ambiguous ${name} persists UNKNOWN_RESULT without replay or further requests`, async () => { + const f = fixture(), original = f.context.request.bind(f.context) + f.context.request = async (method, path, body, ...args) => { + if (path.includes('/mark-return')) { f.requests.push({ method, path, body: structuredClone(body) }); return response } + return original(method, path, body, ...args) + } + await assert.rejects(run(f), /outcome unknown/) + const last = f.checkpoints.at(-1).requests.at(-1) + assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, response.http) + assert.ok(last.body.idempotencyKey) + assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) + assert.equal(f.requests.at(-1).path, last.path) + const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) + +test('correlated explicit business rejection remains RESPONDED and also stops without replay', async () => { + const f = fixture(), original = f.context.request.bind(f.context) + f.context.request = async (method, path, body, ...args) => { + if (path.includes('/mark-return')) { + f.requests.push({ method, path, body }); return { http: 409, body: { code: 'A0409', message: 'version conflict' }, requestId: 'r', traceId: 't' } + } + return original(method, path, body, ...args) + } + await assert.rejects(run(f), /HTTP failed/) + assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED') + assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) +}) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs new file mode 100644 index 000000000..54f6bfb61 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -0,0 +1,204 @@ +import { randomUUID, createHash } from 'node:crypto' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' + +const BASE = '/api/platform/v1/last-leg' +const id = value => value == null ? '' : String(value) +const key = () => `redispatch-${randomUUID()}` +const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` +const ok = response => response?.http === 200 && String(response?.body?.code) === '200' +const copy = value => value === undefined ? undefined : structuredClone(value) + +function definiteResponse(response) { + const http = response?.http, body = response?.body + if (!Number.isInteger(http) || http < 200 || http >= 500 || !body || typeof body !== 'object' || Array.isArray(body) + || !response.requestId || !response.traceId) return false + const code = id(body.code) + return http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400) +} + +export function selectReturnSources(rows) { + const domestic = rows.filter(row => row.tail?.orderType === 'FIRST_LEG' && row.tail.sourceInboundId && row.tail.inboundCode === row.relation.inboundCode) + const a = domestic.find(row => Number(row.tail.packageCount) > 2 && row.relation.returnSources?.some(source => Number(source.maxReturnPackages) >= 2)) + const b = domestic.find(row => a && row.tail.sourceInboundId !== a.tail.sourceInboundId) + if (!a || !b) throw Error('Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B') + for (const selected of [a, b]) { + const packages = domestic.filter(row => row.tail.sourceInboundId === selected.tail.sourceInboundId) + .reduce((sum, row) => sum + (row.relation.returnSources || []).reduce((n, source) => n + Number(source.maxReturnPackages), 0), 0) + if (packages !== Number(selected.tail.packageCount)) throw Error('Current departure must cover each selected source completely; no old shipments may fill the fixture') + } + return { a, b } +} + +/** Fresh-run-only stage. Every helper HTTP request is checkpointed before dispatch, including multipart metadata. */ +export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { + const run = context.createdResources?.fleetAcceptance + if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker + || run.tripNo !== trip.tripNo || run.departure?.status !== 'IN_TRANSIT' || !input?.pickerToken + || id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId) + || JSON.stringify((run.departure.dispatches || []).map(row => row.dispatchNo).sort()) !== JSON.stringify([...(input.dispatchNos || [])].sort()) + || !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function') + throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart') + const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2, + readOnlyEvidenceRequired: ['Before redispatch signature: source A completion outbox count=0 and domestic inbound not DELIVERED; source B completion outbox count=1', + 'After redispatch signature and exact replay: A and B each have exactly one OVERSEAS_DELIVERY_COMPLETED outbox event by customer+sourceInboundId; inspect delivery ACK and domestic inbound completion separately', + 'Join return receipt ledger_id and source_outbound_no to immutable RETURN_STOCK_IN; join new picks by return_receipt_id; original shipment and signed facts remain unchanged'] } + const check = (label, condition) => { if (!condition) throw Error(label) } + const io = Object.create(context) + io.assert = (...args) => { context.assert(...args); if (!args[3]) throw Error(args[1]) } + async function invoke(method, path, body, token, file, options) { + const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' } + if (file) entry.file = { name: file.name, type: file.type, bytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } + proof.requests.push(entry); await context.checkpoint() + try { + const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) + if (!definiteResponse(response)) { + Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId }) + await context.checkpoint() + throw Error(`HTTP transport outcome unknown: ${method} ${path}; preserve the original request, do not resend`) + } + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId, result: copy(response?.body?.data) }) + await context.checkpoint() + check(`Missing HTTP correlation: ${method} ${path}`, response?.requestId && response?.traceId) + return response + } catch (error) { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } throw error } + } + io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options) + io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file) + async function request(method, path, body, token) { + const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data + } + const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete, + wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies } + const customerId = id(input.customerId), warehouseId = id(input.warehouseId) + const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId)) + const delivery = number => request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}`, customerId)) + async function domestic(selected) { + const page = await request('GET', `/api/platform/inbounds?${new URLSearchParams({ tenantId: customerId, inboundCode: selected.relation.inboundCode, page: '1', size: '200' })}`) + const matches = (page.records || []).filter(row => row.inboundCode === selected.relation.inboundCode) + check('Domestic source must resolve uniquely through its current inbound code', matches.length === 1 && matches[0].processStatus) + return matches[0] + } + async function domesticBoundary(a, b, allSigned) { + const snapshots = [] + for (let attempt = 0; attempt < 30; attempt++) { + const A = await domestic(a), B = await domestic(b); snapshots.push({ A, B }) + if (!allSigned) check('Incomplete A must never be marked delivered domestically', A.processStatus !== 'DELIVERED') + if (B.processStatus === 'DELIVERED' && (!allSigned || A.processStatus === 'DELIVERED')) return snapshots + if (attempt < 29) await deps.wait(1000) + } + throw Error(allSigned ? 'Domestic source completion has not converged after redispatch signature' : 'Independent source B domestic completion did not converge') + } + try { + const rows = [] + proof.originalDeliveries = [] + for (const number of [...new Set(input.deliveryNos)]) { + const current = await delivery(number) + check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo)) + proof.originalDeliveries.push(copy(current.order)) + if (current.order.status === 'WAIT_DELIVERY') { + check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version)) + && current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0) + const started = await request('PUT', scoped(`${BASE}/delivery-orders/${number}/status`, customerId), { + status: 'DELIVERING', expectedVersion: Number(current.order.version), remark: `${context.marker} current-run delivery underway`, + }) + check('Return candidates are opened only after formal delivery start', started.deliveryStatus === 'DELIVERING') + } + const relations = await request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}/return-inbound-options`, customerId)) + for (const relation of relations) { + check('Return source relation belongs to the original delivery', relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo) + const detail = await tail(relation) + check('Tail source customer and warehouse match this run', id(detail.order.customerId) === customerId && id(detail.order.overseasWarehouseId) === warehouseId) + rows.push({ relation, tail: detail.order, delivery: current.order }) + } + } + const { a, b } = selectReturnSources(rows) + proof.domesticBefore = { A: await domestic(a), B: await domestic(b) } + check('Both source inbounds must be unfinished in this fresh sample', proof.domesticBefore.A.processStatus !== 'DELIVERED' && proof.domesticBefore.B.processStatus !== 'DELIVERED') + proof.sources = { A: copy(a), B: copy(b) }; await context.checkpoint() + const source = a.relation.returnSources.find(row => Number(row.maxReturnPackages) >= 2) + const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), { + inboundOrderId: a.relation.inboundOrderId, reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', + returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(), + }) + proof.exceptionNo = marked.exceptionNo + const exceptionPath = `${BASE}/exception-orders/${encodeURIComponent(marked.exceptionNo)}` + const resource = `/api/platform/v1/overseas-warehouse-resources/${warehouseId}` + const palletCode = `RET-${randomUUID()}` + const palletId = id(await request('POST', `${resource}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker })) + const pallet = await request('GET', `${resource}/pallets/${palletId}`) + const locationPage = await request('GET', `${resource}/locations?page=1&size=200`) + const location = (Array.isArray(locationPage) ? locationPage : locationPage.records || []).find(row => id(row.id) === id(pallet.locationId)) + check('New return pallet has a real same-warehouse location', id(pallet.warehouseId) === warehouseId && location?.locationCode && pallet.palletCode === palletCode) + const started = await request('POST', scoped(`${exceptionPath}/start-processing`, customerId), { expectedVersion: marked.version, idempotencyKey: key(), handlingAction: '实际退回2箱' }) + const uploaded = await io.requestMultipart('POST', scoped(`${exceptionPath}/evidence`, customerId), { + name: `${context.marker}-return.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} synthetic evidence of 2 returned boxes`), + }) + check('Return evidence uploaded through owned exception API', ok(uploaded) && uploaded.body.data?.fileId) + const receipt = { expectedVersion: started.version, idempotencyKey: key(), handlingResult: '原出库2箱实际退回入仓', evidenceFileIds: [uploaded.body.data.fileId], + receipts: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, receivedPackages: 2, + targetPalletId: palletId, targetPalletNo: palletCode, targetLocationId: location.id, targetLocationCode: location.locationCode }] } + const restored = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) + check('Exactly two boxes restored', restored.status === 'COMPLETED' && Number(restored.restoredPackages) === 2) + const replay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) + check('Same return key replays the same adjustment', replay.replayed === true && id(replay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId)) + proof.returned = { palletId, palletCode, adjustmentId: restored.inventoryAdjustmentId } + proof.originalSignatures = [] + for (const [index, number] of input.deliveryNos.entries()) { + const current = await delivery(number) + const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index) + check('Original remaining cargo actually signed', signed?.order?.signedAt) + proof.originalSignatures.push(copy(signed.order)) + } + const beforeA = await tail(a.relation), beforeB = await tail(b.relation) + check('A remains incomplete while independently complete B is delivered', !beforeA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && beforeB.order.subOrderStatus?.includes('DELIVERY:DELIVERED')) + proof.beforeRedispatch = { A: beforeA, B: beforeB } + proof.domesticBeforeRedispatch = await domesticBoundary(a, b, false) + const candidates = await request('GET', `${BASE}/dispatches/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: a.relation.inboundCode })}`) + const matches = candidates.filter(row => row.sourceType === 'RETURN_RECEIPT' && id(row.warehousePalletId) === palletId && row.returnReceiptNo && id(row.orderId) === id(a.relation.lastMileOrderId)) + check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].packageCount) === 2 && !matches[0].planPalletId) + const selected = matches[0], draftToken = key() + const container = await request('POST', `${BASE}/dispatches/drafts/containers`, { draftToken, capacity: 30 }) + await request('POST', `${BASE}/dispatches/draft-locks/lock`, { sourceType: 'RETURN_RECEIPT', returnReceiptId: selected.returnReceiptId, draftToken, draftContainerId: container.id }) + const arrange = { planPalletIds: [], returnReceiptIds: [selected.returnReceiptId], draftToken } + const arranged = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) + check('Redispatch is a new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) && Number(arranged.packageCount) === 2) + const arrangedReplay = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) + check('Draft replay keeps the same redispatch', arrangedReplay.dispatchNo === arranged.dispatchNo) + const newTrip = await deps.prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId) + proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = selected.returnReceiptNo; proof.redispatchNo = arranged.dispatchNo + const tripDetail = await request('GET', `${BASE}/fleet/trips/${newTrip.tripNo}`) + const transfer = await request('POST', `${BASE}/dispatches/${arranged.dispatchNo}/transfer-delivery`, { + idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), deliveryMethod: 'TRUCK_PALLET', + pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', + vehiclePlate: tripDetail.trip.vehiclePlate, driverName: tripDetail.trip.driverName, driverPhone: tripDetail.trip.driverPhone, appointmentAccount: 'SELF', + fees: [{ feeType: 'DELIVERY_FEE', feeName: '退回重派验收费用', direction: 'RECEIVABLE', currency: 'CNY', amount: 1 }], remark: context.marker, + }) + check('Redispatch produces a new single destination delivery', transfer.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)) + check('Fresh return stock can be picked via PDA', await deps.pickAll(io, input.pickerToken, arranged.dispatchNo)) + const recordVersion = await deps.confirmFleetLoading(io, module, newTrip) + const departBody = { recordVersion, idempotencyKey: key(), remark: '退回2箱重新实际发车' } + const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart` + const departed = await request('POST', departPath, departBody, input.pickerToken) + check('New DEPART ships exactly two returned boxes', departed.tripNo === newTrip.tripNo && Number(departed.shippedPackages) === 2) + const departedAgain = await request('POST', departPath, departBody, input.pickerToken) + check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2) + const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) + const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271) + check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && Number(newSignature.order.signedPackages) === 2) + const afterA = await tail(a.relation), afterB = await tail(b.relation) + check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus) + for (const original of proof.originalSignatures) { + const current = (await delivery(original.deliveryNo)).order + check('Original signed facts stay immutable', current.signedAt === original.signedAt && Number(current.signedPackages) === Number(original.signedPackages) && Number(current.packageCount) === Number(original.packageCount)) + } + proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed } + proof.domesticAfterRedispatch = await domesticBoundary(a, b, true) + proof.closure = await deps.finishFleetTrip(io, module, newTrip) + check('New trip cargo and resource lifecycle completes', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + proof.status = 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'; proof.sourceEventsVerified = false + await context.checkpoint(); return proof + } catch (error) { proof.status = 'FAILED'; proof.failure = error.message; await context.checkpoint(); throw error } +} diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 471b485ad..942946248 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -12,6 +12,7 @@ import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-exter import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' +import { verifyFleetReturnRedispatch } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' @@ -23,7 +24,7 @@ const success = response => response?.http === 200 && id(response?.body?.code) = const key = () => `fleet-${randomUUID()}` export function parseFleetArguments(args) { - const config = { execute: false, environment: 'TEST', runId: 'plan' } + const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'handover' } let action = null const seen = new Set() for (let index = 0; index < args.length; index++) { @@ -32,7 +33,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'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') + 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 (seen.has(name)) throw Error('Duplicate argument') seen.add(name) const value = args[++index] @@ -40,16 +41,21 @@ 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 === '--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 (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') return config } export function fleetExecutionPlan(config) { - return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, + 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', @@ -430,6 +436,13 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol }) stage('RECEIPTS_AND_RESOURCE_RELEASE') proof.receipts = [] + if (context.context.fleetScenario === 'return-redispatch') { + stage('RETURN_REDISPATCH') + const returned = await verifyFleetReturnRedispatch(context, MODULE, trip, { customerId, warehouseId, pickerToken: picker.token, + dispatchNos, deliveryNos: [...partial.proof.deliveryNos, external.deliveryNo] }) + proof.receipts = returned.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + context.context.saveFleetReturnRedispatchFixture(returned) + } else { 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, '签收前读取本次实际派送') @@ -437,6 +450,7 @@ export async function runFleetScenario(context, { wait = ms => new Promise(resol check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) } + } proof.closure = await finishFleetTrip(context, MODULE, trip) check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') stage('HANDOVER_RESOURCE_CLEANUP') @@ -485,7 +499,9 @@ export async function runFleetAcceptance(config, { env = process.env, readers, c markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) context = createContext ? createContext(options) : new AcceptanceRun(options) context.context.fleetRunId = config.runId + context.context.fleetScenario = config.scenario || 'handover' context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) + context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture) let handoverCheckpoint = 0 context.context.checkpointFleetHandover = proof => { const payload = JSON.stringify(redactEvidence({ marker: context.marker, runId: config.runId, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index e7f7a7713..7cf1e9561 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -18,6 +18,14 @@ test('fleet entry defaults to a no-side-effect plan and requires explicit TEST a for (const output of ['reports/../outside', 'C:/temp/output', 'reports', 'reports/nested/path']) assert.throws(() => parseFleetArguments(['--output', output])) }) +test('return redispatch is explicit, fresh-only and declares source-event proof separately', () => { + const config = parseFleetArguments(['--plan', '--environment', 'TEST', '--run-id', 'return-new', '--scenario', 'return-redispatch']) + const plan = fleetExecutionPlan(config) + assert.equal(plan.scenario, 'return-redispatch'); assert.ok(plan.returnRedispatch.required.includes('Two current-run domestic sources')) + assert.throws(() => parseFleetArguments(['--scenario', 'return-redispatch', '--resume-report', 'reports/old/report.json', '--resume-stage', 'FULL_COVERAGE']), /fresh run/) + assert.throws(() => parseFleetArguments(['--scenario', 'unknown'])) +}) + test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { const order = [], departure = { tripNo: 'TR-NEW', status: 'IN_TRANSIT' } const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } },