2026-10-21 21:30:24 +08:00
import assert from 'node:assert/strict'
import { mkdirSync , writeFileSync , readFileSync , existsSync } from 'node:fs'
import { resolve , join } from 'node:path'
import { tmpdir } from 'node:os'
import { createHash } from 'node:crypto'
import { pathToFileURL } from 'node:url'
import { AcceptanceRun , parseArguments , validateExecutionOptions } from './admin-test-env-acceptance.mjs'
import { run as fixedCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
import { resolvePickerAccess , createPicker , disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs'
import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
import { prepareBolAppointments } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs'
const ROOT = '/api/platform/v1/last-leg' , PDA = '/api/pda/v1/last-leg'
const slots = [ 'LOADING_SCENE' , 'LOADING_LIST' , 'PRE_DEPARTURE_CHECK' , 'DRIVER_LICENSE' , 'RECEIPT' ]
const id = value => String ( value ?? '' )
const hash = bytes => createHash ( 'sha256' ). update ( bytes ). digest ( 'hex' )
export function parseFlowArguments ( args ) {
const config = { execute : false , keepPda : false }
for ( let i = 0 ; i < args . length ; i ++ ) {
if ( args [ i ] === '--execute' ) config . execute = true
else if ( args [ i ] === '--keep-pda-for-ui' ) config . keepPda = true
2026-10-23 01:04:27 +08:00
else if ( args [ i ] === '--stop-after-draft' ) config . stopAfterDraft = true
2026-10-21 21:30:24 +08:00
else {
const key = { '--run-id' : 'runId' , '--warehouse-id' : 'warehouseId' , '--destination-warehouse-id' : 'destinationId' , '--test-image' : 'image' , '--resume-report' : 'resumeReport' }[ args [ i ]]
if ( ! key || config [ key ] || ! args [ i + 1 ] || args [ i + 1 ]. startsWith ( '--' )) throw Error ( 'Unsupported or duplicate argument' )
config [ key ] = args [ ++ i ]
}
}
if ( config . execute && ( ! /^[A-Za-z0-9_-]{1,48}$/ . test ( config . runId || '' ) || ! /^[1-9]\d*$/ . test ( config . warehouseId || '' ) || ! /^[1-9]\d*$/ . test ( config . destinationId || '' ) || ! config . image )) throw Error ( 'Explicit run, warehouses and synthetic image required' )
2026-10-23 01:04:27 +08:00
if ( config . stopAfterDraft && config . resumeReport ) throw Error ( 'Draft UI preparation requires a fresh attempt' )
2026-10-21 21:30:24 +08:00
return config
}
export async function runLoadingFlow ( config , env = process . env ) {
if ( ! config . execute ) return { execute : false , scope : 'TEST fixed customer, two received pallets, confirmed FBA appointment, reviews, picks, five photos, automatic outbounds/deliveries; optional UI session handoff. No SQL, reset or deployment.' }
if ( env . CHONGCHONG_ENV !== 'test' || ! env . ADMIN_TEST_FIXED_CUSTOMER_ID ) throw Error ( 'Explicit TEST and configured fixed customer required' )
const directory = resolve ( 'reports' , `dispatch-loading- ${ config . runId } ` )
if ( existsSync ( directory )) throw Error ( 'Existing attempt: inspect original report; never blindly replay' )
const image = readFileSync ( config . image ); assert . equal ( image . subarray ( 1 , 4 ). toString (), 'PNG' )
mkdirSync ( directory ,{ recursive : true }); mkdirSync ( join ( directory , 'requests' ))
const base = new URL ( env . ADMIN_TEST_GATEWAY )
const options = validateExecutionOptions ({... parseArguments ([ '--modules' , 'merchant-qualification-fixture' , '--quiet' , 'true' ]), gateway : base . origin , username : env . ADMIN_TEST_USERNAME , password : env . ADMIN_TEST_PASSWORD , environment : 'test' , output : directory })
const context = new AcceptanceRun ( options ), sessions = [], secrets = [], report = { environment : 'TEST' , marker : context . marker , syntheticTestMaterials : true , hardwareVerified : false , members : [], checks : [], success : false }
const previous = config . resumeReport ? JSON . parse ( readFileSync ( config . resumeReport , 'utf8' )) : null
if ( previous ){ assert . equal ( previous . environment , 'TEST' ); assert . equal ( previous . customerId , env . ADMIN_TEST_FIXED_CUSTOMER_ID ); assert ( previous . dispatchNo && previous . members . length === 2 && previous . members . every ( m => m . receiptVerified && m . customerId === previous . customerId )); report . members = previous . members ; report . dispatchNo = previous . dispatchNo ; report . resumedFrom = config . resumeReport }
let sequence = 0 , picker
const clean = value => Array . isArray ( value ) ? value . map ( clean ) : value && typeof value === 'object' ? Object . fromEntries ( Object . entries ( value ). map (([ key , v ])=>[ key , /password|token|authorization/i . test ( key ) ? '[REDACTED]' : clean ( v )])) : typeof value === 'string' && secrets . includes ( value ) ? '[REDACTED]' : value
const save = ()=> writeFileSync ( join ( directory , 'report.json' ), JSON . stringify ( clean ({... report , requestCount : sequence , createdResources : context . createdResources }), null , 2 ))
context . checkpoint = async ()=> save ()
const check = ( label , condition )=>{ report . checks . push ({ label , passed :!! condition }); save (); assert ( condition , label )}
const key = action => hash ( ` ${ context . marker } : ${ config . runId } : ${ action } ` )
for ( const method of [ 'request' , 'requestMultipart' ]) {
const original = context [ method ]. bind ( context )
context [ method ] = async (... args )=>{
const file = join ( directory , 'requests' , ` ${ ++ sequence } .json` ), record = { method : args [ 0 ], path : args [ 1 ], body : method === 'request' ? clean ( args [ 2 ]) : { fileName : args [ 2 ] ? . name }, stage : 'INTENT' }
writeFileSync ( file , JSON . stringify ( record , null , 2 ))
try { const response = await original (... args ); if ( args [ 1 ] === '/api/auth/login' && response . body ? . data ? . accessToken ){ secrets . push ( response . body . data . accessToken , response . body . data . refreshToken ); sessions . push ( response . body . data )} record . stage = 'RESPONSE' ; record . response = clean ( response ); writeFileSync ( file , JSON . stringify ( record , null , 2 )); save (); return response }
catch ( error ){ record . stage = 'UNKNOWN' ; record . error = error . message ; writeFileSync ( file , JSON . stringify ( record , null , 2 )); throw error }
}
}
const send = async ( method , path , body , token = context . token , deny = false )=>{
const response = await context . request ( method , path , body , token ,{})
if ( deny ){ check ( `rejected ${ path } ` , id ( response . body ? . code ) === 'A0409' ); return response . body }
if ( response . http !== 200 || id ( response . body ? . code ) !== '200' ) throw Error ( ` ${ method } ${ path } : HTTP ${ response . http } , ${ response . body ? . code } , ${ response . body ? . message } , requestId= ${ response . requestId } ` )
return response . body . data
}
const upload = async ( path , name , token = context . token )=>{
const r = await context . requestMultipart ( 'POST' , path ,{ name , type : 'image/png' , content : image }, token )
if ( r . http !== 200 || id ( r . body ? . code ) !== '200' ) throw Error ( `Upload failed ${ path } : ${ r . body ? . message } , requestId= ${ r . requestId } ` )
return r . body . data
}
const phase = value =>{ report . phase = value ; save (); console . log ( JSON . stringify ({ phase : value , dispatchNo : report . dispatchNo }))}
try {
const auth = await send ( 'POST' , '/api/auth/login' ,{ loginId : options . username , password : options . password }, '' ); context . token = auth . accessToken ; context . refreshToken = auth . refreshToken
await fixedCustomer ( context ,{ requireDistinctCustomer : false }); const customer = context . context . merchantQualificationFixture
const merchant = await send ( 'POST' , '/api/auth/login' ,{ loginId : customer . username , password : customer . password }, '' ); delete context . context . merchantQualificationFixture
report . customerId = id ( customer . tenantId )
const access = await resolvePickerAccess ( context ); assert ( access , 'Existing PDA role missing' )
picker = await createPicker ( context , 82 , config . warehouseId , access ); assert ( picker ? . token , 'PDA login failed' ); secrets . push ( picker . password )
const master = await send ( 'GET' , `/api/platform/v1/warehouse-admin/ ${ config . destinationId } ?tenantId=0` )
assert ( master . warehouse . status === 'ACTIVE' && master . warehouse . warehouseType === 'FBA' && master . warehouse . timezone . includes ( '/' ))
const address = master . addresses . find ( a => a . addressLine1 ), own = `?customerId= ${ report . customerId } `
if ( ! previous ){
phase ( 'RECEIVING_TWO_TEST_PALLETS' )
for ( const suffix of [ 'A' , 'B' ]) {
const m = { customerId : report . customerId , marker : context . marker , receiptVerified : false }; report . members . push ( m )
const merchantRoot = '/api/merchant/v1/last-leg/warehouse-transfers'
const body = { sourceLastlegOrderIds : [], operatingWarehouseId : config . warehouseId , fbaWarehouseCode : master . warehouse . warehouseCode , destinationType : 'FBA' , destinationWarehouseId : config . destinationId , destinationWarehouseCode : master . warehouse . warehouseCode , destinationWarehouseName : master . warehouse . warehouseName , deliveryAddress : [ address . countryCode , address . city , address . addressLine1 , address . postalCode ]. filter ( Boolean ). join ( ' ' ), packageCount : 1 , volumeCbm : 1 , deliveryMethod : 'CUSTOMER_DELIVERY' , estimatedArrivalAt : new Date ( Date . now () + 3600000 ). toISOString (). slice ( 0 , 19 ), needRelabel : false , remark : ` ${ context . marker } TEST synthetic ${ suffix } ` , idempotencyKey : key ( `CREATE- ${ suffix } ` ), fbaShipmentNo : `TEST-FBA- ${ config . runId } - ${ suffix } ` , items : [{ skuCode : `TEST- ${ config . runId } - ${ suffix } ` , productNameZh : 'TEST验收货物' , packageCount : 1 , quantity : 1 }]}
let incoming = await send ( 'POST' , merchantRoot , body , merchant . accessToken ); m . incomingNo = incoming . transferNo ; save ()
const mp = ` ${ merchantRoot } / ${ encodeURIComponent ( m . incomingNo ) } ` , pp = ` ${ PDA } /warehouse-transfers/ ${ encodeURIComponent ( m . incomingNo ) } `
await send ( 'POST' , mp + '/submit' , undefined , merchant . accessToken )
await send ( 'POST' , ` ${ ROOT } /warehouse-transfers/ ${ encodeURIComponent ( m . incomingNo ) } /review ${ own } ` ,{ decision : 'APPROVE' , remark : context . marker })
const task = await send ( 'GET' , pp , undefined , picker . token ); assert . equal ( task . cartons . length , 1 )
const draft = await send ( 'POST' , pp + '/pallets/drafts' ,{ idempotencyKey : key ( `DRAFT- ${ suffix } ` )}, picker . token ), palletPath = pp + '/pallets/' + draft . pallets [ 0 ]. id
const generated = await send ( 'POST' , palletPath + '/generate' ,{ idempotencyKey : key ( `GENERATE- ${ suffix } ` )}, picker . token )
m . palletId = id ( generated . pallets [ 0 ]. warehousePalletId ); m . palletNo = generated . pallets [ 0 ]. palletNo
await send ( 'POST' , palletPath + '/bind' ,{ palletCode : m . palletNo , idempotencyKey : key ( `BIND- ${ suffix } ` )}, picker . token )
await send ( 'POST' , pp + '/cartons/count' ,{ idempotencyKey : key ( `COUNT- ${ suffix } ` ), forecastPackageCount : 1 , boxes : [{ cartonMark : task . cartons [ 0 ]. cartonMark , transferItemId : id ( task . items [ 0 ]. id ), transferPalletId : id ( draft . pallets [ 0 ]. id ), length : 100 , width : 100 , height : 100 , weight : 100 , items : [{ skuCode : task . items [ 0 ]. skuCode , quantity : 1 }]}]}, picker . token )
const photo = await upload ( pp + '/measurement-photos/files' , ` ${ config . runId } -TEST-measurement.png` , picker . token )
await send ( 'POST' , pp + '/palletize' ,{ idempotencyKey : key ( `PALLETIZE- ${ suffix } ` ), photoFileIds : [ id ( photo . fileId )]}, picker . token )
incoming = await send ( 'POST' , mp + '/confirm-measurement' ,{ idempotencyKey : key ( `CUSTOMER- ${ suffix } ` ), remark : context . marker }, merchant . accessToken )
check ( 'received ' + m . incomingNo , incoming . status === 'READY_FOR_DISPATCH' )
const grouped = await send ( 'GET' , ` ${ ROOT } /inventory/balances?` + new URLSearchParams ({ customerId : m . customerId , overseasWarehouseId : config . warehouseId , inboundCode : m . incomingNo , page : '1' , size : '100' }))
assert . equal ( grouped . records . length , 1 , 'One exact receipt balance expected' ); const balance = grouped . records [ 0 ]
m . balanceId = id ( balance . id ); m . receipt = { balance : await send ( 'GET' , ` ${ ROOT } /inventory/balances/ ${ m . balanceId }${ own } ` )}
const positions = await send ( 'GET' , ` ${ ROOT } /inventory/balances/ ${ m . balanceId } /positions ${ own } ` ); m . positionId = id ( positions . find ( p => id ( p . palletId ) === m . palletId ). id )
const candidates = await send ( 'GET' , ` ${ ROOT } /dispatches/candidates?` + new URLSearchParams ({ warehouseId : config . warehouseId , orderNo : m . incomingNo }))
const candidate = candidates . candidates . find ( c => c . palletNo === m . palletNo && id ( c . customerId ) === m . customerId ); assert ( candidate ? . selection ); m . selection = candidate . selection ; m . destination = candidate . destination ; m . receiptVerified = true ; save ()
}
phase ( 'CONFIRMED_TEST_APPOINTMENT' )
const allocations = await prepareBolAppointments ( context , report . members , 'bol-same' , config . warehouseId )
for ( const m of report . members ){ const fresh = await send ( 'GET' , ` ${ ROOT } /dispatches/candidates?` + new URLSearchParams ({ warehouseId : config . warehouseId , orderNo : m . incomingNo })); m . selection = fresh . candidates . find ( c => c . palletNo === m . palletNo ). selection }
const created = await send ( 'POST' , ROOT + '/dispatches' ,{ warehouseId : config . warehouseId , vehicle : null , driver : null , plannedDepartureAt : null , estimatedArrivalAt : null , remark : ` ${ context . marker } TEST HTTP/UI` , cargo : report . members . map ( m => m . selection ), batchAllocations : allocations , idempotencyKey : key ( 'DISPATCH' )})
report . dispatchNo = created . dispatchNo ; assert ( report . dispatchNo ); save ()
}
const path = ROOT + '/dispatches/' + report . dispatchNo , pp = PDA + '/dispatches/' + report . dispatchNo
2026-10-23 01:04:27 +08:00
if ( config . stopAfterDraft ){
const draft = await send ( 'GET' , path )
check ( 'UI fixture remains an unpicked draft' , draft . stage === 'BEFORE_LOADING' && draft . reviewState === 'DRAFT' && Number ( draft . counts . pickedPackageCount ) === 0 )
report . final = draft ; report . executionScope = 'DRAFT_FIXTURE_ONLY' ; report . fullFlowPassed = false ; report . success = true
phase ( 'DRAFT_READY_FOR_UI' )
return { success : true , executionScope : report . executionScope , dispatchNo : report . dispatchNo , report : join ( directory , 'report.json' )}
}
2026-10-21 21:30:24 +08:00
if ( ! previous ){ const created = await send ( 'GET' , path ); await send ( 'POST' , path + '/submit-review' ,{ recordVersion : created . recordVersion , idempotencyKey : key ( 'NO_SCHEDULE' ), remark : 'TEST missing transport schedule must block' }, context . token , true )}
phase ( 'REVIEWS_AND_REAL_PICKS' )
if ( previous ){
const current = await send ( 'GET' , path ), proof = previous . createdResources . fleetTrips [ 0 ]
assert . equal ( current . stage , 'BEFORE_LOADING' ); assert . equal ( current . reviewState , 'DRAFT' ); assert . equal ( Number ( current . counts . pickedPackageCount ), 0 ); assert . equal ( id ( proof . warehouseId ), config . warehouseId )
2026-10-22 23:50:23 +08:00
assert . equal ( previous . success , false )
assert (( previous . createdResources . fleetTripCommands || []). every ( c => c . status === 'RESPONDED' && c . http === 200 && String ( c . code ) !== '200' ), 'Only a pre-write failure or definite rejected preparation may resume' )
report . trip = await prepareFleetTrip ( context , 'dispatch-loading-flow' , report . dispatchNo , config . warehouseId )
2026-10-21 21:30:24 +08:00
} else report . trip = await prepareFleetTrip ( context , 'dispatch-loading-flow' , report . dispatchNo , config . warehouseId )
for ( const [ index , m ] of report . members . entries ()) {
const task = await send ( 'GET' , pp , undefined , picker . token ), item = task . items . find ( i => i . palletNo === m . palletNo ); assert ( item ); m . dispatchItemId = id ( item . dispatchItemId )
const body = { recordVersion : task . recordVersion , positionId : m . positionId , packages : 1 , productQuantity : 1 , idempotencyKey : key ( 'PICK-' + index ), remark : context . marker }
const picked = await send ( 'POST' , ` ${ pp } /items/ ${ m . dispatchItemId } /pick` , body , picker . token )
const replay = await send ( 'POST' , ` ${ pp } /items/ ${ m . dispatchItemId } /pick` , body , picker . token ); check ( 'pick replay ' + index , replay . replayed === true && id ( replay . recordVersion ) === id ( picked . recordVersion ))
if ( index === 0 ){ const preview = await send ( 'GET' , pp + '/loading' , undefined , picker . token ); await send ( 'POST' , pp + '/loading-complete' ,{ recordVersion : preview . recordVersion , manifestHash : preview . manifestHash , idempotencyKey : key ( 'INCOMPLETE' )}, picker . token , true )}
}
const ledger = async ()=> Promise . all ( report . members . map ( m => send ( 'GET' , ` ${ ROOT } /inventory/ledger?` + new URLSearchParams ({ customerId : m . customerId , overseasWarehouseId : config . warehouseId , inboundCode : m . incomingNo , page : '1' , size : '100' }))))
report . stockAfterPick = await ledger ()
phase ( 'PDA_PHOTOS_AND_ADMIN_FILE_READBACK' )
2026-10-26 19:21:11 +08:00
const initialDocuments = await send ( 'GET' , pp + '/loading-documents' , undefined , picker . token ), reviewRound = id ( initialDocuments . reviewRound ), uploadedFiles = new Set ()
assert . match ( reviewRound , /^[1-9]\d*$/ )
for ( const slot of slots ){ const uploadKey = key ( 'UPLOAD-' + slot ); const up = await upload ( ` ${ pp } /loading-documents/ ${ slot } /uploads?idempotencyKey= ${ uploadKey } &reviewRound= ${ reviewRound } ` , ` ${ config . runId } -TEST- ${ slot } .png` , picker . token ); const docs = await send ( 'GET' , pp + '/loading-documents' , undefined , picker . token ); assert . equal ( id ( docs . reviewRound ), reviewRound ); await send ( 'PUT' , ` ${ pp } /loading-documents/ ${ slot } ` ,{ recordVersion : docs . recordVersion , idempotencyKey : key ( 'BIND-' + slot ), fileId : id ( up . fileId ), uploadKey }, picker . token ); uploadedFiles . add ( id ( up . fileId ))}
const docs = await send ( 'GET' , path + '/loading-documents' ); check ( 'Admin sees imported and five added photos' , docs . complete && docs . photos . length === initialDocuments . photos . length + 5 )
2026-10-21 21:30:24 +08:00
report . photos = []
2026-10-26 19:21:11 +08:00
for ( const photo of docs . photos ){ const r = await context . requestBinary ( 'GET' , ` ${ path } /loading-documents/ ${ photo . slot } /files/ ${ photo . fileId } /content` ); check ( 'Admin bytes ' + photo . slot , r . http === 200 && r . bytes . length > 0 && ( ! uploadedFiles . has ( id ( photo . fileId )) || hash ( r . bytes ) === hash ( image ))); report . photos . push ({ slot : photo . slot , fileId : photo . fileId , sha256 : hash ( r . bytes )})}
2026-10-21 21:30:24 +08:00
phase ( 'AUTOMATIC_OUTBOUND_AND_DELIVERY' )
const preview = await send ( 'GET' , pp + '/loading' , undefined , picker . token ), body = { recordVersion : preview . recordVersion , manifestHash : preview . manifestHash , idempotencyKey : key ( 'LOAD' ), remark : 'TEST scanned pallets; no manual measurements' }
const loaded = await send ( 'POST' , pp + '/loading-complete' , body , picker . token ); check ( 'automatic departure' , loaded . stage === 'DELIVERING' )
const after = await send ( 'GET' , path ), replay = await send ( 'POST' , pp + '/loading-complete' , body , picker . token )
check ( 'loading replay' , replay . replayed && id ( replay . recordVersion ) === id ( loaded . recordVersion ))
assert . deepEqual ( await send ( 'GET' , path ), after ); assert . deepEqual ( await ledger (), report . stockAfterPick )
check ( 'one customer outbound and destination delivery' , after . documents . outbounds . length === 1 && after . documents . deliveries . length === 1 && after . documents . outbounds [ 0 ]. status === 'OUTBOUNDED' && after . documents . deliveries [ 0 ]. status === 'DELIVERING' && Number ( after . counts . shippedPackageCount ) === 2 )
report . final = after ; report . success = true ; phase ( 'HTTP_PASSED_AWAITING_UI' )
if ( config . keepPda ){ report . privateUiSession = join ( tmpdir (), `dispatch-ui- ${ config . runId } .json` ); writeFileSync ( report . privateUiSession , JSON . stringify ( picker ),{ mode : 0o600 , flag : 'wx' })}
} catch ( error ){ report . error = error . message ; report . success = false ; phase ( 'FAILED' )}
finally {
if ( picker ? . membershipId && ! ( report . success && config . keepPda )) await disablePicker ( context , picker , 82 ). catch ( e =>{ report . cleanupError = e . message })
for ( const session of sessions ) await context . request ( 'POST' , '/api/auth/logout' ,{ refreshToken : session . refreshToken }, session . accessToken ,{}). catch (()=>{})
save ()
}
return { success : report . success , error : report . error , dispatchNo : report . dispatchNo , report : join ( directory , 'report.json' ), privateUiSession : report . privateUiSession }
}
if ( process . argv [ 1 ] && import . meta . url === pathToFileURL ( resolve ( process . argv [ 1 ])). href ){ try { const result = await runLoadingFlow ( parseFlowArguments ( process . argv . slice ( 2 ))); console . log ( JSON . stringify ( result )); if ( result . success === false ) process . exitCode = 1 } catch ( error ){ console . error ( error . message ); process . exitCode = 1 }}