Files
baiyue-firstleg/scripts/mixed-container-outbound-test-env.mjs
T

392 lines
30 KiB
JavaScript
Raw Normal View History

2026-10-20 10:35:52 +08:00
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'
2026-09-15 15:13:15 +08:00
import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs'
2026-10-20 10:35:52 +08:00
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'
2026-09-15 15:13:15 +08:00
2026-10-20 10:35:52 +08:00
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)
2026-09-15 15:13:15 +08:00
}
2026-10-20 10:35:52 +08:00
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
2026-09-15 15:13:15 +08:00
}
2026-10-20 10:35:52 +08:00
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)
2026-09-15 15:13:15 +08:00
}
2026-10-20 10:35:52 +08:00
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')
2026-09-15 15:13:15 +08:00
}
2026-10-20 10:35:52 +08:00
/** 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}`
2026-09-15 15:13:15 +08:00
if (!result.task) {
const candidates = await api('GET', candidatePath)
const selected = candidates.filter(row => row.inboundCode === inboundCode)
const inputs = selected.map(row => {
const pallet = record.task.pallets.find(p => p.palletNo === row.palletNo)
const cartonMarks = (pallet?.cartons || []).filter(c => String(c.inboundId) === String(record.source.inboundId)).map(c => c.boxCode)
if (cartonMarks.length !== Number(row.availablePackages) || row.customerId !== customerId) throw new Error('Dropship pallet scope mismatch')
return { inventoryPositionId: row.inventoryPositionId, packages: cartonMarks.length, sku: row.skuCode, cartonMarks }
})
if (inputs.length !== 2 || inputs.reduce((n, i) => n + i.packages, 0) !== 4) throw new Error('Expected exactly two pallets and four cartons')
result.selected = selected
result.inputs = inputs
result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` }
2026-10-20 10:35:52 +08:00
await context.checkpoint()
result.task = await api('POST', `${LASTLEG}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody)
await context.checkpoint()
2026-09-15 15:13:15 +08:00
}
2026-10-20 10:35:52 +08:00
const before = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`)
2026-09-15 15:13:15 +08:00
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
2026-10-20 10:35:52 +08:00
await context.checkpoint()
2026-09-15 15:13:15 +08:00
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()) {
const item = detail.items.find(i => i.palletNo === candidate.palletNo && i.inboundCode === inboundCode && i.skuCode === candidate.skuCode)
if (!item || !item.positions.some(p => String(p.inventoryPositionId) === String(candidate.inventoryPositionId))) throw new Error('PDA source position mismatch')
const body = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId,
packages: result.inputs[index].packages, cartonMarks: result.inputs[index].cartonMarks, idempotencyKey: `${context.marker}-PICK-${index}` }
await api('POST', `${path}/pick`, body, picker.token)
await api('POST', `${path}/pick`, body, picker.token)
}
result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token)
}
2026-10-20 10:35:52 +08:00
result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`)
2026-09-15 15:13:15 +08:00
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'
2026-10-20 10:35:52 +08:00
} 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')
2026-09-15 15:13:15 +08:00
}
2026-09-15 15:25:05 +08:00
} catch (error) {
2026-10-20 10:35:52 +08:00
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 }))
2026-09-15 15:25:05 +08:00
}
2026-10-20 10:35:52 +08:00
return result
2026-09-15 15:13:15 +08:00
}
2026-10-20 10:35:52 +08:00
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainMixed()