feat(dispatch): unify whole vehicle scheduling, source ownership and staged execution
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user