feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution

This commit is contained in:
2026-10-19 23:40:42 +08:00
parent e0677e7ae7
commit 9dd34791f7
346 changed files with 29245 additions and 12602 deletions
+346 -129
View File
@@ -1,49 +1,295 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery, verifyDepartureReadiness,
dispatchInventorySources, inventorySnapshot, dispatchStockConservation, inventorySourcesPresent,
relabelOutboundMarksMatch } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs'
import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { f40CreateDispatchBody } from './admin-test-env-modules/phase5-root-fulfillment.mjs'
// Explicit retained fixture continuation, not a new fixture generator.
const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2)
if (!sourcePath || !containerNo || !['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) || !outputPath) {
throw new Error('Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit test authorization required')
const LASTLEG = '/api/platform/v1/last-leg'
const DISPATCH = `${LASTLEG}/dispatches`
const PDA = '/api/pda/v1/last-leg/dispatches'
const MODULE = 'native-retained-outbound'
const id = value => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Retained ID lost precision')
return value == null ? '' : String(value)
}
const options = parseArguments([])
validateExecutionOptions(options)
const context = new AcceptanceRun(options)
const source = JSON.parse(readFileSync(resolve(sourcePath), 'utf8'))
const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label)
if (!record || record.overseas?.plan?.containerNo !== containerNo || !record.overseas?.receiving?.receivingVerified) {
throw new Error('Fixture must be the explicitly selected, completed mixed-container receipt')
const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value)) && /^(0|[1-9]\d*)$/.test(String(value))
const success = response => response?.http === 200 && String(response?.body?.code) === '200'
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
const requireFact = (condition, message) => { if (!condition) throw Error(message) }
const sorted = values => [...new Set(values)].sort()
const redact = value => {
if (Array.isArray(value)) return value.map(redact)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
.filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name)).map(([name, nested]) => [name, redact(nested)]))
return value
}
const inboundCode = record.source.inboundCode
const customerId = record.overseas.plan.customerId
const output = resolve(outputPath)
const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : {
marker: context.marker, containerNo, inboundCode, label, source: resolve(sourcePath), events: [],
export function saveRetainedReport(output, result) {
mkdirSync(dirname(output), { recursive: true })
writeFileSync(`${output}.tmp`, `${JSON.stringify(redact({ ...result, updatedAt: new Date().toISOString() }), null, 2)}\n`)
renameSync(`${output}.tmp`, output)
}
if (result.inboundCode !== inboundCode || result.containerNo !== containerNo || result.label !== label) throw new Error('Checkpoint scope mismatch')
context.marker = result.marker
const save = () => writeFileSync(output, JSON.stringify({ ...result, updatedAt: new Date().toISOString() }, null, 2))
const base = '/api/platform/v1/last-leg'
async function api(method, path, body, token) {
const response = await context.requestOverseas(method, path, body, {}, token || context.overseasToken)
result.events.push({ method, path, http: response.http, code: response.body?.code,
message: response.body?.message, requestId: response.requestId, traceId: response.traceId })
save()
if (response.http !== 200 || String(response.body?.code) !== '200') throw new Error(`${method} ${path}: ${response.body?.message}`)
return response.body.data
export function validateRetainedScope(result, scope) {
requireFact(result.schema === 'NATIVE_RETAINED_OUTBOUND_V1' && result.environment === 'TEST'
&& result.marker === scope.marker && result.sourceSha256 === scope.sourceSha256
&& result.customerId === id(scope.customerId) && result.warehouseId === id(scope.warehouseId)
&& same(result.inboundCodes, scope.inboundCodes), 'Retained native marker/source/customer/warehouse scope mismatch; legacy reports cannot resume')
requireFact(!result.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)),
'Unknown write outcome retained: reconcile the original command before further writes')
}
let picker
try {
const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
if (!login.body?.data?.overseasAccessToken) throw new Error('Platform login failed')
context.token = login.body.data.accessToken
context.overseasToken = login.body.data.overseasAccessToken
if (label === 'MIXED-DROPSHIP') {
const candidatePath = `${base}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}`
/** Every business write is saved before sending. Unknown results never generate another key. */
export function installRetainedJournal(context, result, save) {
const request = context.request.bind(context), multipart = context.requestMultipart?.bind(context)
context.createdResources = result.resources ||= {}
context.createdResources.phase5OverseasFulfillment = result.fulfillment ||= {}
context.context ||= {}
context.context.phase5InventorySources = result.inventorySources || {}
context.checkpoint = async () => { result.inventorySources = context.context.phase5InventorySources; await save() }
result.requests ||= []
let stopped = false
const execute = async (method, path, body, send, file) => {
if (method === 'GET' || path.startsWith('/api/auth/')) return send()
const cleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(path) && body?.status === 'DISABLED'
if (stopped && !cleanup) throw Error('Unknown write outcome: retained business writes stopped')
const entry = { method, path, ...(file ? { file } : { body: structuredClone(body) }), status: 'SENDING' }
result.requests.push(entry); await context.checkpoint()
let response
try { response = await send() }
catch (error) { stopped = true; entry.status = 'UNKNOWN'; await context.checkpoint(); throw error }
const missingNativeResult = success(response) && (path.startsWith(DISPATCH) || path.startsWith(`${LASTLEG}/fleet/`))
&& (response.body?.data == null || typeof response.body.data !== 'object')
const known = response?.http > 0 && response.http < 500 && response.body?.code != null && !missingNativeResult
Object.assign(entry, { status: known ? 'RESPONDED' : 'UNKNOWN', http: response?.http,
code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId, result: response?.body?.data })
if (entry.status === 'UNKNOWN') stopped = true
await context.checkpoint()
if (stopped && !cleanup) throw Error('Unknown write outcome retained; no automatic retry or replacement command')
return response
}
context.request = (method, path, body, token, headers) => execute(method, path, body, () => request(method, path, body, token, headers))
context.requestMultipart = (method, path, file, token, headers) => {
const bytes = Buffer.from(file.content)
return execute(method, path, undefined, () => multipart(method, path, file, token, headers),
{ name: file.name, type: file.type, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') })
}
return context
}
export async function retainedApi(context, method, path, body, token) {
const response = await context.request(method, path, body, token)
const data = context.apiSuccess(MODULE, `${method} ${path}`, response, '', { deploymentBlocker: true })
if (!success(response)) throw Error(`Retained request failed: ${method} ${path}; HTTP=${response?.http}; code=${response?.body?.code}`)
return data
}
function sourceKey(selection) {
if (selection.sourceType === 'INBOUND_PLAN') return `PLAN:${id(selection.planPalletId)}`
if (selection.sourceType === 'RELABEL') return `RELABEL:${selection.relabelNo}:${id(selection.warehousePalletId)}`
throw Error('Unsupported retained source selector')
}
export function validateNativeRetainedDispatch(native, scope) {
requireFact(native?.marker === scope.marker && native?.dispatchNo && native.createBody?.idempotencyKey
&& id(native.createBody.warehouseId) === id(scope.warehouseId) && native.createBody.cargo?.length
&& native.createResult?.dispatchNo === native.dispatchNo && native.createResult?.action === 'CREATE'
&& native.createResult?.status === 'ACTIVE' && native.createResult?.stage === 'BEFORE_LOADING'
&& validVersion(native.createResult.recordVersion) && native.detail?.dispatchNo === native.dispatchNo
&& native.detail.status === 'ACTIVE' && validVersion(native.detail.recordVersion)
&& Array.isArray(native.detail.orderDetails) && native.detail.orderDetails.length,
'Retained dispatch requires native Create command/result/Detail and the original marker; dispatch number alone is insufficient')
const keys = native.createBody.cargo.map(sourceKey)
requireFact(new Set(keys).size === keys.length && same(sorted(keys), sorted(native.detail.orderDetails.map(row => row.sourceKey)))
&& native.detail.orderDetails.every(row => id(row.customerId) === id(scope.customerId)), 'Retained native source/customer identity mismatch')
requireFact(native.createBody.cargo.every(row => /^[a-f\d]{64}$/i.test(row.sourceVersion || '')
&& /^[1-9]\d*$/.test(id(row.warehousePalletId))), 'Retained selectors require original immutable source versions and physical pallet IDs')
return keys
}
async function ledger(context, scope, dispatchNo) {
const rows = []; let total
for (let page = 1; page <= 50; page++) {
const params = new URLSearchParams({ customerId: id(scope.customerId), businessType: 'SHIP', businessOrderNo: dispatchNo,
outboundOrderNo: dispatchNo, page: String(page), size: '200' })
const data = await retainedApi(context, 'GET', `${LASTLEG}/inventory/ledger?${params}`)
requireFact(Array.isArray(data.records) && Number.isSafeInteger(Number(data.total)), 'Incomplete retained SHIP ledger')
total ??= Number(data.total); requireFact(Number(data.total) === total, 'Ledger snapshot changed during pagination')
rows.push(...data.records)
if (rows.length >= total) break
requireFact(data.records.length > 0, 'Truncated retained SHIP ledger')
}
requireFact(rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length
&& rows.every(row => id(row.customerId) === id(scope.customerId) && row.businessType === 'SHIP'
&& row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo), 'Foreign or duplicated retained shipment ledger')
return rows.map(row => ({ ...row, id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId),
beforePackages: Number(row.beforePackages), deltaPackages: Number(row.deltaPackages), afterPackages: Number(row.afterPackages) })).sort((a, b) => a.id.localeCompare(b.id))
}
const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness,
saveReceiptAndComplete, finishFleetTrip, inventorySnapshot, dispatchInventorySources, dispatchStockConservation, ledger }
/** Continue only a proved native dispatch. All mutations remain real public API commands. */
export async function runRetainedNativeDispatch(context, result, scope, pdaToken, overrides = {}) {
const deps = { ...NATIVE_DEPS, ...overrides }
const api = (method, path, body, token) => retainedApi(context, method, path, body, token)
validateRetainedScope(result, scope)
context.marker = scope.marker
let native = result.nativeDispatch
requireFact(native, 'No original native dispatch proof; create it from current authoritative candidates first')
const expectedSources = validateNativeRetainedDispatch(native, scope), dispatchNo = native.dispatchNo, path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}`
result.dispatchNo = dispatchNo
let detail = await api('GET', path)
const command = await api('GET', `${path}/commands/${encodeURIComponent(native.createBody.idempotencyKey)}`)
requireFact(command.dispatchNo === dispatchNo && command.action === 'CREATE'
&& String(command.recordVersion) === String(native.createResult.recordVersion)
&& same(command.result, native.createResult.result), 'Original CREATE command is absent or differs from retained production receipt')
const items = await api('GET', `${path}/items`)
requireFact(detail.status === 'ACTIVE' && validVersion(detail.recordVersion)
&& same(sorted(items.items?.map(row => row.sourceKey) || []), sorted(expectedSources))
&& items.items.every(row => id(row.customerId) === id(scope.customerId) && scope.inboundCodes.includes(row.inboundCode))
&& Number(detail.counts?.packageCount) === scope.expectedPackages, 'Live native cargo/customer/quantity drift')
const task = await api('GET', `${PDA}/${encodeURIComponent(dispatchNo)}`, undefined, pdaToken)
requireFact(task.dispatchNo === dispatchNo && id(task.warehouseId) === id(scope.warehouseId)
&& Array.isArray(task.items) && task.items.length > 0 && (!scope.expectedPalletItems || task.items.length === scope.expectedPalletItems)
&& task.items.every(row => id(row.customerId) === id(scope.customerId))
&& task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) === scope.expectedPackages, 'PDA warehouse/customer/item quantity differs from the original physical sources')
const bol = await context.requestBinary('GET', `${path}/prints/bol`)
requireFact(bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && Buffer.from(bol.bytes || []).subarray(0, 5).toString() === '%PDF-', 'BOL is not a real PDF')
result.bol = { sha256: createHash('sha256').update(bol.bytes).digest('hex'), size: bol.bytes.length }
let trip = result.tripProof || (context.createdResources.fleetTrips || []).find(row => row.dispatchNo === dispatchNo)
if (detail.stage === 'BEFORE_LOADING') {
requireFact(!trip, 'Partly prepared resources already exist; retain the original commands instead of creating replacements')
trip = await deps.prepareFleetTrip(context, MODULE, dispatchNo, id(scope.warehouseId)); result.tripProof = trip
detail = await api('GET', path)
}
requireFact(trip?.preparation === 'VERIFIED' && trip.dispatchNo === dispatchNo && id(trip.warehouseId) === id(scope.warehouseId), 'Native resource/review proof is missing or incomplete')
result.tripProof = trip
if (detail.stage === 'LOADING') {
if (!result.inventory) {
requireFact(Number(task.counts?.pickedPackageCount) === 0, 'Cannot reconstruct a pre-scan inventory baseline after physical scanning')
const sources = deps.dispatchInventorySources(task, dispatchNo, id(scope.warehouseId))
requireFact(sources, 'Missing real PDA source positions/balances')
result.inventory = { ...sources,
stockBefore: await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-before-pick', sources.sourceBalanceIds),
ledgerBefore: await deps.ledger(context, scope, dispatchNo) }
requireFact(result.inventory.stockBefore && inventorySourcesPresent(result.inventory.stockBefore, sources.sourcePositionBalances), 'Incomplete original pre-scan source inventory')
await context.checkpoint()
}
requireFact(await deps.pickAll(context, pdaToken, dispatchNo), 'PDA real picks failed')
result.inventory.stockAfter = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-pick', result.inventory.sourceBalanceIds)
result.inventory.ledgerAfter = await deps.ledger(context, scope, dispatchNo)
const conservation = deps.dispatchStockConservation(context, { ...result.inventory, customerId: id(scope.customerId), warehouseId: id(scope.warehouseId),
dispatchNo, expectedPackages: scope.expectedPackages })
result.inventory.conservation = conservation.proof
requireFact(conservation.passed, 'Physical inventory/SHIP conservation failed')
await context.checkpoint()
try { await deps.confirmFleetLoading(context, MODULE, trip, pdaToken, result.loadingMeasurements || native.loadingMeasurements) }
catch (error) {
const field = ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'].find(value => String(error?.message).includes(value))
result.blocker = { stage: 'LOADING_COMPLETE', code: field ? 'ACTUAL_MEASUREMENT_REQUIRED' : 'LOADING_COMPLETE_NOT_VERIFIED', field: field || null }
await context.checkpoint(); throw error
}
detail = await api('GET', path)
}
requireFact(result.inventory?.conservation, 'No original scan quantity/ledger proof for this retained native dispatch')
const recovered = action => result.requests?.find(row => row.status === 'RESPONDED' && String(row.code) === '200' && row.result?.dispatchNo === dispatchNo && row.result?.action === action)?.result
if (!result.transfer) {
const saved = recovered('ARRANGE_DELIVERY')
if (saved) result.transfer = { data: saved, deliveries: saved.result.deliveries, outbounds: saved.result.outbounds }
else {
requireFact(detail.stage === 'AFTER_LOADING', 'Missing original arrangement command; do not borrow existing historical delivery rows')
result.transfer = await deps.arrangeDispatchDelivery(context, dispatchNo)
requireFact(result.transfer, 'Native arrangement failed')
}
await context.checkpoint()
}
const deliveries = result.transfer.deliveries, outbounds = result.transfer.outbounds
requireFact(Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length === 1
&& id(outbounds[0].customerId) === id(scope.customerId) && deliveries.every(row => id(row.customerId) === id(scope.customerId)
&& row.outboundNo === outbounds[0].outboundNo) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === scope.expectedPackages,
'Native arrangement does not conserve the retained customer and physical quantity')
result.outboundNo = outbounds[0].outboundNo; result.deliveryNos = deliveries.map(row => row.deliveryNo)
if (!result.departure) result.departure = recovered('DEPART')
if (detail.stage === 'AFTER_LOADING') {
const ready = await deps.verifyDepartureReadiness(context, pdaToken, dispatchNo)
requireFact(ready && validVersion(ready.data.recordVersion), 'Native server DEPART gates unavailable')
result.departBody ||= { recordVersion: ready.data.recordVersion, idempotencyKey: `${scope.marker}-DEPART`.slice(0, 128), remark: `${scope.marker}-TEST` }
await context.checkpoint()
result.departure = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken)
const replay = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken)
requireFact(replay.replayed === true && String(replay.recordVersion) === String(result.departure.recordVersion)
&& same(replay.result, result.departure.result), 'Departure original command replay drift')
result.departureReplay = replay
detail = await api('GET', path)
}
requireFact(result.departure?.stage === 'DELIVERING' && result.departure?.status === 'ACTIVE'
&& Number(result.departure.result?.shippedPackages) === scope.expectedPackages
&& same(sorted(result.departure.result?.deliveries?.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`) || []),
sorted(deliveries.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`)))
&& ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original native departure receipt missing or invalid')
result.inventory.stockAfterDepart = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-depart', result.inventory.sourceBalanceIds)
result.inventory.ledgerAfterDepart = await deps.ledger(context, scope, dispatchNo)
requireFact(same(result.inventory.stockAfter, result.inventory.stockAfterDepart)
&& same(result.inventory.ledgerAfter, result.inventory.ledgerAfterDepart), 'Departure repeated SHIP or changed inventory')
result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${encodeURIComponent(result.outboundNo)}`)
requireFact(result.outbound.outbound?.status === 'OUTBOUNDED' && id(result.outbound.outbound.customerId) === id(scope.customerId)
&& result.outbound.outbound.outboundNo === result.outboundNo && result.outbound.outbound.dispatchNo === dispatchNo
&& Number(result.outbound.outbound.packageCount) === scope.expectedPackages, 'Actual retained outbound is not complete')
if (scope.relabelCartonMarks) requireFact(relabelOutboundMarksMatch(result.outbound, scope), 'New relabel carton marks were lost, duplicated or replaced with old marks')
result.deliveries ||= []
for (const [index, delivery] of deliveries.entries()) {
const deliveryPath = `${LASTLEG}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}?customerId=${encodeURIComponent(scope.customerId)}`
const current = await api('GET', deliveryPath)
requireFact(id(current.order?.customerId) === id(scope.customerId) && current.order.outboundNo === result.outboundNo, 'Delivery customer/outbound identity drift')
if (current.order.status !== 'DELIVERED') {
requireFact(current.order.status === 'DELIVERING', 'Actual native departure must advance delivery before POD')
requireFact(await deps.saveReceiptAndComplete(context, delivery, id(scope.customerId), index), 'POD did not automatically complete delivery')
} else {
const original = result.requests.find(row => row.method === 'PUT' && row.path === `${LASTLEG}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}/documents?customerId=${encodeURIComponent(scope.customerId)}` && row.status === 'RESPONDED' && row.result?.categories)
requireFact(original, 'Already signed delivery has no original retained POD save command')
const replay = await api('PUT', original.path, original.body)
requireFact(replay.replayed === true && String(replay.version) === String(original.result.version), 'POD original-command replay drift')
}
const signed = await api('GET', deliveryPath)
requireFact(signed.order?.status === 'DELIVERED' && signed.order.signedAt, 'Real signedAt/POD completion missing')
result.deliveries[index] = signed; await context.checkpoint()
}
for (let attempt = 0; attempt < 30; attempt++) {
detail = await api('GET', path)
if (detail.stage === 'DELIVERED') break
await new Promise(resolve => setTimeout(resolve, 1000))
}
requireFact(detail.status === 'ACTIVE' && detail.stage === 'DELIVERED', 'All normal signatures did not aggregate native DELIVERED')
result.nativeCompleted = detail
if (!trip.closure) result.tripClosure = await deps.finishFleetTrip(context, MODULE, trip)
else requireFact(trip.closure.status === 'VERIFIED', 'Retained resource closure is incomplete; preserve original release commands')
result.finalStatus = 'PASSED'; await context.checkpoint(); return result
}
export async function mainMixed(args = process.argv.slice(2)) {
const [sourcePath, containerNo, label, outputPath] = args
requireFact(sourcePath && containerNo && ['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) && outputPath,
'Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit TEST authorization required')
const options = parseArguments([]); options.modules = []; validateExecutionOptions(options)
requireFact(options.environment === 'test', 'Retained mixed outbound only supports explicit TEST')
const sourceBytes = readFileSync(resolve(sourcePath)), source = JSON.parse(sourceBytes)
const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label)
requireFact(source.environment === 'test' && source.marker && record?.overseas?.plan?.containerNo === containerNo
&& record.overseas?.receiving?.receivingVerified, 'Explicit current TEST receipt source is required')
const inboundCode = record.source.inboundCode, customerId = id(record.overseas.plan.customerId), warehouseId = id(record.overseas.warehouseId)
const output = resolve(outputPath), scope = { marker: `${source.marker}-${label}`, customerId, warehouseId, inboundCodes: [inboundCode], expectedPackages: 4,
sourceSha256: createHash('sha256').update(sourceBytes).digest('hex') }
const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST',
...scope, containerNo, inboundCode, label, source: resolve(sourcePath), requests: [] }
validateRetainedScope(result, scope)
const context = installRetainedJournal(new AcceptanceRun(options), result, () => saveRetainedReport(output, result))
context.marker = scope.marker
const api = (method, path, body, token) => retainedApi(context, method, path, body, token)
let picker
try {
const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '')
requireFact(success(login) && login.body?.data?.accessToken, 'Unified platform login failed')
context.token = login.body.data.accessToken
if (label === 'MIXED-DROPSHIP') {
const candidatePath = `${LASTLEG}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}`
if (!result.task) {
const candidates = await api('GET', candidatePath)
const selected = candidates.filter(row => row.inboundCode === inboundCode)
@@ -57,17 +303,17 @@ try {
result.selected = selected
result.inputs = inputs
result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` }
save()
result.task = await api('POST', `${base}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody)
save()
await context.checkpoint()
result.task = await api('POST', `${LASTLEG}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody)
await context.checkpoint()
}
const before = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`)
const before = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`)
if (before.outbound.status !== 'OUTBOUNDED') {
const access = await resolvePickerAccess(context)
picker = access ? await createPicker(context, 9, record.overseas.warehouseId, access) : null
if (!picker?.token) throw new Error('PDA operator unavailable')
result.pickerMembershipId = picker.membershipId
save()
await context.checkpoint()
const path = `/api/pda/v1/last-leg/outbound-tasks/${result.task.taskNo}`
const detail = await api('GET', path, undefined, picker.token)
for (const [index, candidate] of result.selected.entries()) {
@@ -80,95 +326,66 @@ try {
}
result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token)
}
result.outbound = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`)
result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`)
result.remainingCandidates = (await api('GET', candidatePath)).filter(row => row.inboundCode === inboundCode)
result.finalStatus = result.outbound.outbound.status === 'OUTBOUNDED' && result.remainingCandidates.length === 0
? 'OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED' : 'OUTBOUND_FAILED'
} else {
const candidates = await api('GET', `${base}/dispatches/candidates?sourceStage=RECEIVED&orderNo=${encodeURIComponent(record.overseas.summary.orderNo)}`)
const selected = candidates.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo)
if (!result.dispatchNo) {
if (selected.reduce((sum, row) => sum + Number(row.packageCount), 0) !== 4 || selected.some(row => row.fbaWarehouseCode !== 'ABE4')) throw new Error('Live FBA candidate quantity or destination changed')
// The caller must retain the checkpoint after every mutation; do not retry creation without checking live candidates.
const dispatch = await api('POST', `${base}/dispatches/pre-arrange`, { planPalletIds: selected.map(row => row.planPalletId) })
result.dispatchNo = dispatch.dispatchNo
save()
}
const dispatchPath = `${base}/dispatches/${encodeURIComponent(result.dispatchNo)}`
let dispatch = await api('GET', dispatchPath)
if (!result.transfer) {
await api('PUT', dispatchPath, { vehiclePlate: `TEST-${context.marker.slice(-8)}`, driverName: 'HTTP测试司机',
driverPhone: '19900000000', carrierName: 'HTTP测试车队', estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString() })
result.transfer = await api('POST', `${dispatchPath}/transfer-delivery`, {
idempotencyKey: `${context.marker}-TRANSFER`, plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(),
deliveryMethod: 'TRUCK_PALLET', carrierName: 'HTTP测试车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: 'HTTP测试司机', driverPhone: '19900000000',
vehiclePlate: `TEST-${context.marker.slice(-8)}`, appointmentAccount: 'SELF', remark: `${context.marker}-test-only`,
fees: [{ feeType: 'DELIVERY_FEE', feeName: '测试派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 1, remark: `${context.marker}-test-only` }],
})
save()
}
dispatch = await api('GET', dispatchPath)
if (dispatch.status !== 'DEPARTED') {
const access = await resolvePickerAccess(context)
picker = access ? await createPicker(context, 8, record.overseas.warehouseId, access) : null
if (!picker?.token) throw new Error('PDA operator unavailable')
result.pickerMembershipId = picker.membershipId
save()
const tripTask = await api('GET', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}`, undefined, picker.token)
if (!tripTask.tripNo) throw new Error('Retained dispatch must be assigned to a qualified fleet trip before picking; prepare through the current fleet API')
const trip = await api('GET', `${base}/fleet/trips/${encodeURIComponent(tripTask.tripNo)}`)
if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed')
const tripVersion = await confirmFleetLoading(context, 'mixed-container-outbound', { tripNo: tripTask.tripNo, stops: trip.trip.stops })
result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`,
{ recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: `${context.marker}-test-only` }, picker.token)
if (result.departure.status !== 'IN_TRANSIT' || !result.departure.dispatches?.some(item => item.dispatchNo === result.dispatchNo)) throw new Error('Whole-trip departure result missing retained dispatch')
save()
}
const deliveries = result.transfer.deliveries
if (!Array.isArray(deliveries) || deliveries.length !== 1 || Number(result.transfer.packageCount) !== 4) throw new Error('Unexpected transfer quantities')
result.deliveries = []
for (const [index, delivery] of deliveries.entries()) {
const path = `${base}/delivery-orders/${delivery.deliveryNo}?customerId=${customerId}`
const detail = await api('GET', path)
if (detail.order.status !== 'DELIVERED') {
if (detail.order.status !== 'DELIVERING') await api('PUT', `${base}/delivery-orders/${delivery.deliveryNo}/status?customerId=${customerId}`,
{ status: 'DELIVERING', expectedVersion: detail.order.version, remark: `${context.marker}-test-only` })
if (!await saveReceiptAndComplete(context, delivery, customerId, index)) throw new Error('Receipt signature failed')
} else {
if (!result.nativeDispatch) {
const savedCreate = result.requests.find(row => row.method === 'POST' && row.path === DISPATCH && row.status === 'RESPONDED'
&& String(row.code) === '200' && row.result?.action === 'CREATE')
if (savedCreate) {
requireFact(result.createBody && same(savedCreate.body, result.createBody), 'Original native CREATE body must remain exact')
const detail = await api('GET', `${DISPATCH}/${encodeURIComponent(savedCreate.result.dispatchNo)}`)
result.nativeDispatch = { marker: scope.marker, dispatchNo: savedCreate.result.dispatchNo, createBody: savedCreate.body, createResult: savedCreate.result, detail,
loadingMeasurements: record.loadingMeasurements }
await context.checkpoint()
}
}
if (!result.nativeDispatch) {
const page = await api('GET', `${DISPATCH}/candidates?warehouseId=${warehouseId}`)
const selected = page?.candidates?.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo
&& id(row.customerId) === customerId && id(row.warehouseId) === warehouseId)
requireFact(page?.complete === true && selected?.length && selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 4
&& selected.every(row => row.destination?.warehouseCode === 'ABE4'), 'Live native FBA source/destination/quantity changed')
result.createBody ||= f40CreateDispatchBody(scope.marker, warehouseId, customerId, selected)
await context.checkpoint()
const created = await api('POST', DISPATCH, result.createBody)
const detail = await api('GET', `${DISPATCH}/${encodeURIComponent(created.dispatchNo)}`)
result.nativeDispatch = { marker: scope.marker, dispatchNo: created.dispatchNo, createBody: result.createBody, createResult: created, detail,
loadingMeasurements: record.loadingMeasurements }
await context.checkpoint()
}
const access = await resolvePickerAccess(context)
picker = access ? await createPicker(context, 8, warehouseId, access) : null
requireFact(picker?.token, 'PDA operator unavailable')
result.pickerMembershipId = picker.membershipId; await context.checkpoint()
await runRetainedNativeDispatch(context, result, scope, picker.token)
for (let attempt = 0; attempt < 30; attempt++) {
const data = await api('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${encodeURIComponent(inboundCode)}&page=1&size=20`)
result.domesticInbound = data.records?.find(row => row.inboundCode === inboundCode)
if (result.domesticInbound?.processStatus === 'DELIVERED') break
await new Promise(resolve => setTimeout(resolve, 1000))
}
requireFact(result.domesticInbound?.orderId, 'Missing original domestic source order')
result.domesticOrder = (await api('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`))?.summary
requireFact(result.domesticInbound.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED', 'Domestic source completion callback did not converge')
}
result.deliveries.push(await api('GET', path))
save()
}
result.outbound = await api('GET', `${base}/outbound-orders/${result.transfer.outboundNo}`)
for (let attempt = 0; attempt < 30; attempt++) {
const response = await context.request('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${inboundCode}&page=1&size=20`)
if (String(response.body?.code) !== '200') throw new Error('Domestic inbound query failed')
result.domesticInbound = response.body.data.records.find(row => row.inboundCode === inboundCode)
if (result.domesticInbound?.processStatus === 'DELIVERED') break
await new Promise(resolve => setTimeout(resolve, 1000))
}
if (!result.domesticInbound?.orderId) throw new Error('Missing domestic source order')
const order = await context.request('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`)
result.domesticOrder = order.body?.data?.summary
result.finalStatus = result.domesticInbound?.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED'
&& result.outbound?.outbound?.status === 'OUTBOUNDED' ? 'PASSED' : 'STATE_CALLBACK_FAILED'
}
} catch (error) {
result.error = error.message
result.finalStatus = 'BLOCKED'
process.exitCode = 1
} finally {
try {
await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8)
} catch (error) {
result.cleanupError = error.message
result.finalStatus = 'CLEANUP_FAILED'
result.finalStatus = 'BLOCKED'; result.failedStage = result.nativeDispatch?.dispatchNo ? 'NATIVE_FULFILLMENT' : 'SOURCE_PREPARATION'
process.exitCode = 1
} finally {
try { await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) }
catch { result.finalStatus = 'CLEANUP_FAILED' }
finally { if (picker) picker.token = null }
result.checks = context.steps
if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED'
if (result.finalStatus !== 'PASSED') process.exitCode = 1
await context.checkpoint()
console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus,
dispatchNo: result.dispatchNo, outboundNo: result.outboundNo || result.task?.outboundNo, deliveryNos: result.deliveryNos, report: output }))
}
result.checks = context.steps
if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED'
if (result.finalStatus !== 'PASSED') process.exitCode = 1
save()
console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error,
dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo || result.task?.outboundNo,
deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output }))
return result
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainMixed()