350 lines
32 KiB
JavaScript
350 lines
32 KiB
JavaScript
import {existsSync,openSync,writeFileSync,fsyncSync,closeSync,readFileSync} from 'node:fs'
|
|
import {join,resolve} from 'node:path'
|
|
import {redactEvidence} from '../fba-core-regression-test-env.mjs'
|
|
import { randomUUID, createHash } from 'node:crypto'
|
|
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs'
|
|
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './phase5-overseas-fulfillment.mjs'
|
|
import { version, createBody, candidatePage, readDispatch, replay as replayCommand } from './grouped-dispatch-acceptance.mjs'
|
|
|
|
const BASE = '/api/platform/v1/last-leg'
|
|
const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Unsafe server ID; retain Long string'); return 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 }
|
|
}
|
|
|
|
/** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */
|
|
export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) {
|
|
const run = context.createdResources?.fleetAcceptance
|
|
if (Object.keys(dependencies).some(name => !['wait', 'onBeforeRedispatchSignature'].includes(name))) throw Error('Only readonly audit and wait observers may be supplied; native production, picking and signature cannot be replaced')
|
|
const departure = run?.departure
|
|
if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker
|
|
|| run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART'
|
|
|| !departure.result?.departedAt || !input?.pickerToken
|
|
|| id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId)
|
|
|| JSON.stringify([departure.dispatchNo]) !== JSON.stringify([...(input.dispatchNos || [])].sort())
|
|
|| JSON.stringify((departure.result.deliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].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) }
|
|
let stopped = false
|
|
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) {
|
|
if (stopped) throw Error('Unknown prior outcome; preserve original command and stop')
|
|
const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' }
|
|
if (file) entry.file = { name: file.name, type: file.type, sizeBytes: 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)) {
|
|
stopped = true; proof.outcomeUnknown = true
|
|
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') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; 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 = { onBeforeRedispatchSignature: dependencies.onBeforeRedispatchSignature || context.context?.onBeforeRedispatchSignature,
|
|
wait: dependencies.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))) }
|
|
const native = { command: (method, path, body, label) => request(method, path, body), check: (label, response, passed) => check(label, passed) }
|
|
const replay = async (path, body, first, label) => { const again = await replayCommand(native, path, body, first, label); check(label, again.replayed === true); return again }
|
|
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')
|
|
}
|
|
async function roots(a, b, beforeSignature) {
|
|
const snapshots = []
|
|
const known = value => ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'].includes(value)
|
|
for (const [name, selected] of [['A', a], ['B', b]]) {
|
|
const sourceId = id(selected.tail.sourceOrderId)
|
|
check('Original source order provenance is required for root and child guards', /^[1-9]\d*$/.test(sourceId))
|
|
const rawPath = order => `/api/platform/v1/orders/first-leg/${encodeURIComponent(order)}?${new URLSearchParams({ tenantId: customerId })}`
|
|
const source = await request('GET', rawPath(sourceId)), order = source?.order
|
|
const rootId = order?.orderRole === 'MASTER' ? id(order.id) : id(order?.rootOrderId)
|
|
check('Original source and fulfillment root belong to current customer', id(order?.id) === sourceId && id(order.customerId) === customerId && id(order.tenantId) === customerId
|
|
&& known(order.status) && /^[1-9]\d*$/.test(rootId) && (order.orderRole === 'MASTER' || order.orderRole === 'CHILD' && id(order.parentOrderId) === rootId))
|
|
const root = rootId === sourceId ? source : await request('GET', rawPath(rootId))
|
|
const tree = await request('GET', `/api/platform/v1/orders/${rootId}/fulfillment-tree?${new URLSearchParams({ tenantId: customerId })}`)
|
|
check('Formal root and explicit child list remain customer-owned', id(root.order?.id) === rootId && root.order.orderRole === 'MASTER' && id(root.order.customerId) === customerId && id(root.order.tenantId) === customerId && known(root.order.status)
|
|
&& id(tree.parentOrderId) === rootId && tree.parentOrderNo === root.order.orderNo && Array.isArray(tree.children) && Array.isArray(root.statusHistory))
|
|
const children = []
|
|
for (const row of tree.children.filter(row => id(row.orderId) !== rootId)) {
|
|
const child = await request('GET', rawPath(id(row.orderId)))
|
|
check('Every formal child stays linked to the actual root', id(child.order?.id) === id(row.orderId) && child.order.orderRole === 'CHILD'
|
|
&& id(child.order.rootOrderId) === rootId && id(child.order.parentOrderId) === rootId && id(child.order.customerId) === customerId && id(child.order.tenantId) === customerId && known(child.order.status) && child.order.orderNo === row.orderNo)
|
|
children.push(child.order)
|
|
}
|
|
check('Original source is present in its real formal root tree', sourceId === rootId || children.some(child => id(child.id) === sourceId))
|
|
const pending = children.some(child => !['COMPLETED', 'CANCELLED'].includes(child.status))
|
|
const completed = root.statusHistory.some(event => event.action === 'OVERSEAS_DELIVERED') && !pending
|
|
check('Root cannot complete ahead of valid children or overseas delivery fact', completed
|
|
? root.order.status === 'COMPLETED' && root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED')
|
|
: root.order.status !== 'COMPLETED' && !root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED'))
|
|
if (beforeSignature && name === 'A') check('Incomplete A child cannot be completed before redispatch signature', order.status !== 'COMPLETED')
|
|
snapshots.push({ source: name, sourceOrder: order, root, tree, children })
|
|
}
|
|
return snapshots
|
|
}
|
|
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)
|
|
&& id(current.order.customerId) === customerId && id(current.order.overseasWarehouseId) === warehouseId)
|
|
proof.originalDeliveries.push(copy(current.order))
|
|
if (input.sourceSelectionRecovery) check('Reviewed source recovery must retain the already started delivery; never start it again', current.order.status === 'DELIVERING')
|
|
if (current.order.status === 'WAIT_DELIVERY') {
|
|
version(current.order.version)
|
|
const started = await request('PUT', scoped(`${BASE}/delivery-orders/${number}/status`, customerId), {
|
|
status: 'DELIVERING', expectedVersion: version(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 and has a real master ID', /^[1-9][0-9]*$/.test(id(relation.lastMileOrderId))
|
|
&& id(relation.customerId) === customerId && id(relation.overseasWarehouseId) === warehouseId
|
|
&& relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo)
|
|
const detail = await tail(relation)
|
|
check('Tail source identity, customer and warehouse match this candidate', id(detail.order.id) === id(relation.lastMileOrderId)
|
|
&& detail.order.orderNo === relation.lastMileOrderNo && detail.order.deliveryOrderNo === number
|
|
&& detail.order.outboundOrderNo === relation.outboundNo
|
|
&& 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 => /^[1-9][0-9]*$/.test(id(row.outboundPalletItemId))
|
|
&& /^[1-9][0-9]*$/.test(id(row.inventoryPositionId)) && row.returnCartonId == null
|
|
&& Number.isSafeInteger(Number(row.maxReturnPackages)) && Number(row.maxReturnPackages) >= 2)
|
|
check('Selected master must contain the actual ordinary stock source for the two returned boxes', Boolean(source))
|
|
const markPath = scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId)
|
|
proof.markReturnRequest = {
|
|
lastMileOrderId: id(a.relation.lastMileOrderId), 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(),
|
|
}
|
|
const marked = await request('POST', markPath, proof.markReturnRequest)
|
|
await replay(markPath, proof.markReturnRequest, marked, 'Mark-return original command replay')
|
|
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 paged = async path => {
|
|
const rows = []; let total
|
|
for (let page = 1; ; page++) {
|
|
const current = await request('GET', `${path}&page=${page}&size=200`)
|
|
check('Inventory pagination must remain complete and stable', Array.isArray(current?.records) && Number.isSafeInteger(Number(current.total)) && Number(current.total) >= 0
|
|
&& (total === undefined || total === Number(current.total)) && (current.records.length > 0 || rows.length === Number(current.total)))
|
|
total ??= Number(current.total); rows.push(...current.records); if (rows.length >= total) { check('Inventory pagination cannot omit or duplicate rows', rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length); return rows }
|
|
}
|
|
}
|
|
const inventory = async label => {
|
|
const filters = new URLSearchParams({ customerId, overseasWarehouseId: warehouseId, inboundCode: a.relation.inboundCode })
|
|
const balances = await paged(`${BASE}/inventory/balances?${filters}`), positions = []
|
|
for (const balance of balances) {
|
|
check('Returned balance belongs to original customer and warehouse', id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId)
|
|
const rows = await request('GET', `${BASE}/inventory/balances/${id(balance.id)}/positions?${new URLSearchParams({ customerId, palletNo: palletCode })}`)
|
|
check('Actual return positions are explicit rows', Array.isArray(rows))
|
|
for (const row of rows.filter(row => id(row.palletId) === palletId)) {
|
|
check('Returned stock identity stays within original customer warehouse and inbound', id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode)
|
|
positions.push(row)
|
|
}
|
|
}
|
|
const ledger = await paged(`${BASE}/inventory/ledger?${filters}&${new URLSearchParams({ businessType: 'RETURN_STOCK_IN', businessOrderNo: marked.exceptionNo, outboundOrderNo: a.delivery.outboundNo, palletNo: palletCode })}`)
|
|
check('Return stock-in ledger is immutable original-shipment evidence', ledger.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId
|
|
&& row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode && row.businessType === 'RETURN_STOCK_IN' && row.businessOrderNo === marked.exceptionNo && row.outboundOrderNo === a.delivery.outboundNo))
|
|
const result = { label, positions, ledger, available: positions.reduce((n, row) => n + Number(row.availablePackages), 0), picked: positions.reduce((n, row) => n + Number(row.pickedPackages), 0) }
|
|
proof.inventory ||= []; proof.inventory.push(copy(result)); return result
|
|
}
|
|
const empty = await inventory('BEFORE_RETURN'); check('Fresh target pallet contains no previous return stock', empty.available === 0 && empty.picked === 0 && empty.ledger.length === 0)
|
|
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 stockIn = await inventory('RETURN_STOCK_IN')
|
|
check('Actual two returned boxes produce exactly one stock-in ledger', stockIn.available === 2 && stockIn.picked === 0 && stockIn.ledger.length === 1 && Number(stockIn.ledger[0].deltaPackages) === 2)
|
|
const restoredReplay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt)
|
|
check('Same return key replays the same adjustment', restoredReplay.replayed === true && id(restoredReplay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId))
|
|
const stockReplay = await inventory('RETURN_REPLAY')
|
|
check('Return replay cannot duplicate stock or ledger', stockReplay.available === stockIn.available && stockReplay.picked === stockIn.picked && JSON.stringify(stockReplay.ledger) === JSON.stringify(stockIn.ledger) && JSON.stringify(stockReplay.positions) === JSON.stringify(stockIn.positions))
|
|
const history = await request('GET', scoped(exceptionPath, customerId))
|
|
check('Owned exception exposes the actual unique returned receipt business number', history?.order?.exceptionNo === marked.exceptionNo && id(history.order.customerId) === customerId
|
|
&& history.order.outboundNo === a.delivery.outboundNo && history.returnReceipts?.length === 1 && history.returnReceipts[0].receiptNo && Number(history.returnReceipts[0].receivedPackages) === 2)
|
|
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 saveReceiptAndComplete(io, current.order, customerId, 171 + index)
|
|
check('Original remaining cargo actually signed with a stored quantity', signed?.order?.signedAt
|
|
&& Number.isSafeInteger(signed.order.signedPackages) && signed.order.signedPackages >= 0)
|
|
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)
|
|
proof.rootsBeforeRedispatch = await roots(a, b, true)
|
|
const candidates = await candidatePage(native, warehouseId, { orderNo: a.relation.inboundCode })
|
|
const matches = candidates.filter(row => row.selection?.sourceType === 'RETURN_RECEIPT' && id(row.selection.warehousePalletId) === palletId
|
|
&& id(row.customerId) === customerId && id(row.warehouseId) === warehouseId && row.orderNo === a.tail.orderNo && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode)
|
|
check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].availablePackages) === 2 && matches[0].selection.planPalletId == null)
|
|
const selected = matches[0]
|
|
proof.createRequest = createBody(context.marker, 'RETURN-REDISPATCH', [selected])
|
|
const arranged = await request('POST', `${BASE}/dispatches`, proof.createRequest)
|
|
check('Redispatch is a native new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo)
|
|
&& arranged.action === 'CREATE' && arranged.status === 'ACTIVE' && arranged.stage === 'BEFORE_LOADING' && Number(arranged.counts?.packageCount) === 2)
|
|
await replay(`${BASE}/dispatches`, proof.createRequest, arranged, 'Native Create original-key replay keeps the same redispatch')
|
|
const actual = await readDispatch(native, arranged.dispatchNo)
|
|
check('Created redispatch readback preserves exact returned receipt customer and stock identity', actual.warehouseId === warehouseId && actual.items.items.length === 1
|
|
&& actual.items.items[0].sourceKey === selected.sourceKey && id(actual.items.items[0].returnReceiptId) === id(selected.selection.returnReceiptId)
|
|
&& id(actual.items.items[0].warehousePalletId) === palletId && id(actual.items.items[0].customerId) === customerId && Number(actual.items.items[0].scheduledPackages) === 2)
|
|
const newTrip = await prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId)
|
|
proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = history.returnReceipts[0].receiptNo; proof.returnReceiptId = selected.selection.returnReceiptId; proof.redispatchNo = arranged.dispatchNo
|
|
check('Fresh return stock can be picked via PDA', await pickAll(io, input.pickerToken, arranged.dispatchNo))
|
|
const picked = await inventory('RETURN_PICKED')
|
|
check('Actual returned stock is picked once without rewriting the stock-in ledger', picked.available === 0 && picked.picked === 2 && JSON.stringify(picked.ledger) === JSON.stringify(stockIn.ledger))
|
|
await confirmFleetLoading(io, module, newTrip, input.pickerToken, input.actualMeasurements)
|
|
const transfer = await arrangeDispatchDelivery(io, arranged.dispatchNo)
|
|
check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)
|
|
&& id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2)
|
|
const current = await request('GET', `${BASE}/dispatches/${encodeURIComponent(arranged.dispatchNo)}`)
|
|
check('New whole is loaded and fully arranged before actual departure', current.status === 'ACTIVE' && current.stage === 'AFTER_LOADING' && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 0)
|
|
const departBody = { recordVersion: version(current.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.dispatchNo === arranged.dispatchNo && departed.action === 'DEPART' && departed.status === 'ACTIVE'
|
|
&& departed.stage === 'DELIVERING' && departed.result?.departedAt && Number(departed.result.shippedPackages) === 2
|
|
&& departed.result.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === transfer.deliveries[0].deliveryNo)
|
|
const departedAgain = await request('POST', departPath, departBody, input.pickerToken)
|
|
const facts = value => { const result = copy(value); delete result.replayed; return JSON.stringify(result) }
|
|
check('Departure replay does not duplicate shipping', departedAgain.replayed === true && facts(departedAgain) === facts(departed))
|
|
const shipped = await inventory('RETURN_DEPARTED'); check('Returned stock ships once and retains the original immutable stock-in ledger', shipped.available === 0 && shipped.picked === 0 && JSON.stringify(shipped.ledger) === JSON.stringify(stockIn.ledger))
|
|
const newDelivery = await delivery(transfer.deliveries[0].deliveryNo)
|
|
proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo,
|
|
departed, originalSignatureCount: proof.originalSignatures.length }
|
|
await context.checkpoint()
|
|
check('Readonly source event audit gate is required before redispatch signature', typeof deps.onBeforeRedispatchSignature === 'function')
|
|
const audit = await deps.onBeforeRedispatchSignature(proof)
|
|
check('Pre-signature audit proves A has zero completion events and B has one', audit?.sourceAEventCount === 0
|
|
&& audit.sourceBEventCount === 1 && /^[a-f0-9]{64}$/.test(audit.auditEvidenceSha256 || ''))
|
|
Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit })
|
|
await context.checkpoint()
|
|
const newSignature = await saveReceiptAndComplete(io, newDelivery.order, customerId, 271)
|
|
check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && 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 && current.signedPackages === original.signedPackages && Number(current.packageCount) === Number(original.packageCount))
|
|
}
|
|
proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed }
|
|
proof.domesticAfterRedispatch = await domesticBoundary(a, b, true)
|
|
proof.rootsAfterRedispatch = await roots(a, b, false)
|
|
proof.closure = await 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 }
|
|
}
|
|
|
|
export async function waitForRedispatchSignatureAudit(context, proof, { root, output, secrets = [],
|
|
wait = ms => new Promise(resolve => setTimeout(resolve, ms)), timeoutMs = 20 * 60 * 1000 } = {}) {
|
|
const gateNonce = randomUUID(), approvalPath = join(output, 'approve-before-redispatch-signature.json')
|
|
const checkpointPath = join(output, 'before-redispatch-signature.json')
|
|
const checkpoint = { gateNonce, marker: context.marker, runId: context.context.fleetRunId,
|
|
originalTripNo: context.createdResources.fleetAcceptance.tripNo, newTripNo: proof.newTripNo,
|
|
sourceAInboundId: proof.sources.A.tail.sourceInboundId, sourceBInboundId: proof.sources.B.tail.sourceInboundId,
|
|
customerId: context.createdResources.fleetAcceptance.customerId, proof, createdResources: context.createdResources }
|
|
const descriptor = openSync(checkpointPath, 'wx')
|
|
try { writeFileSync(descriptor, JSON.stringify(redactEvidence(checkpoint, secrets), null, 2) + '\n'); fsyncSync(descriptor) }
|
|
finally { closeSync(descriptor) }
|
|
const progressDescriptor = openSync(join(output, 'progress.json'), 'w')
|
|
try {
|
|
writeFileSync(progressDescriptor, JSON.stringify(redactEvidence({ stage: 'RETURN_REDISPATCH_PRE_SIGNATURE', marker: context.marker,
|
|
steps: context.steps || [], modules: context.modules || [], createdResources: context.createdResources }, secrets), null, 2) + '\n')
|
|
fsyncSync(progressDescriptor)
|
|
} finally { closeSync(progressDescriptor) }
|
|
console.log('FLEET_SIGNATURE_AUDIT_WAITING=' + JSON.stringify({ checkpointPath, approvalPath, gateNonce, marker: context.marker }))
|
|
const started = Date.now()
|
|
while (!existsSync(approvalPath)) {
|
|
if (Date.now() - started >= timeoutMs) throw Error('Pre-signature audit gate timed out; do not sign or resume automatically')
|
|
await wait(1000)
|
|
}
|
|
const approval = JSON.parse(readFileSync(approvalPath, 'utf8'))
|
|
if (approval.gateNonce !== gateNonce || approval.marker !== checkpoint.marker || approval.newTripNo !== checkpoint.newTripNo
|
|
|| approval.sourceAEventCount !== 0 || approval.sourceBEventCount !== 1 || !/^[a-f0-9]{64}$/.test(approval.auditEvidenceSha256 || '')
|
|
|| !/^reports\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_.-]+)?\.json$/.test(approval.auditEvidencePath || ''))
|
|
throw Error('Pre-signature approval does not match this exact gate and source counts')
|
|
const evidencePath = resolve(root, approval.auditEvidencePath)
|
|
if (!existsSync(evidencePath) || createHash('sha256').update(readFileSync(evidencePath)).digest('hex') !== approval.auditEvidenceSha256)
|
|
throw Error('Pre-signature audit evidence SHA256 mismatch')
|
|
return approval
|
|
}
|