2026-09-15 15:13:15 +08:00
import { readFileSync , writeFileSync , existsSync } from 'node:fs'
import { resolve } from 'node:path'
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'
2026-10-03 06:26:33 +08:00
import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
2026-09-15 15:13:15 +08:00
// 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 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 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 : [],
}
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
}
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 } `
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` }
save ()
result . task = await api ( 'POST' , ` ${ base } /outbound-orders/one-click-dropship?customerId= ${ customerId } ` , result . createBody )
save ()
}
const before = await api ( 'GET' , ` ${ base } /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 ()
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 )
}
result . outbound = await api ( 'GET' , ` ${ base } /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 ()
2026-10-03 06:26:33 +08:00
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 ) } ` )
2026-09-15 15:13:15 +08:00
if ( dispatch . status !== 'READY_TO_DEPART' && ! await pickAll ( context , picker . token , result . dispatchNo )) throw new Error ( 'PDA picking failed' )
2026-10-03 06:26:33 +08:00
const tripVersion = await confirmFleetLoading ( context , 'mixed-container-outbound' , { tripNo : tripTask . tripNo , stops : trip . trip . stops })
2026-09-15 15:13:15 +08:00
result . departure = await api ( 'POST' , `/api/pda/v1/last-leg/dispatches/ ${ result . dispatchNo } /depart` ,
2026-10-03 06:26:33 +08:00
{ 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' )
2026-09-15 15:13:15 +08:00
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' )
}
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 {
2026-09-15 15:25:05 +08:00
try {
await disablePicker ( context , picker , label === 'MIXED-DROPSHIP' ? 9 : 8 )
} catch ( error ) {
result . cleanupError = error . message
result . finalStatus = 'CLEANUP_FAILED'
}
2026-09-15 15:13:15 +08:00
result . checks = context . steps
2026-09-15 15:25:05 +08:00
if ( context . steps . some ( step => step . passed === false )) result . finalStatus = 'CHECK_FAILED'
if ( result . finalStatus !== 'PASSED' ) process . exitCode = 1
2026-09-15 15:13:15 +08:00
save ()
console . log ( JSON . stringify ({ containerNo , inboundCode , finalStatus : result . finalStatus , error : result . error ,
2026-09-15 15:25:05 +08:00
dispatchNo : result . dispatchNo , outboundNo : result . transfer ? . outboundNo || result . task ? . outboundNo ,
deliveryNos : result . transfer ? . deliveries ? . map ( row => row . deliveryNo ), report : output }))
2026-09-15 15:13:15 +08:00
}