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-26 19:47:18 +08:00
else if ( args [ i ] === '--verify-loading-documents' ) config . verifyLoadingDocuments = true
2026-10-21 21:30:24 +08:00
else {
2026-10-26 19:47:18 +08:00
const key = { '--run-id' : 'runId' , '--warehouse-id' : 'warehouseId' , '--destination-warehouse-id' : 'destinationId' , '--test-image' : 'image' , '--resume-report' : 'resumeReport' , '--replacement-vehicle-id' : 'replacementVehicleId' , '--replacement-driver-id' : 'replacementDriverId' }[ args [ i ]]
2026-10-21 21:30:24 +08:00
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-26 19:47:18 +08:00
if ( config . verifyLoadingDocuments && ( config . stopAfterDraft || config . resumeReport || ! /^[1-9]\d*$/ . test ( config . replacementVehicleId || '' ) || ! /^[1-9]\d*$/ . test ( config . replacementDriverId || '' ))) throw Error ( 'Loading document verification requires explicit replacement resources and a fresh full flow' )
if ( ! config . verifyLoadingDocuments && ( config . replacementVehicleId || config . replacementDriverId )) throw Error ( 'Replacement resources require loading document verification' )
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
}
2026-10-26 19:47:18 +08:00
// 二进制助手使用当前会话;串行切换后立即恢复,确保实际覆盖 PDA 文件权限。
const binary = async ( path , token = context . token )=>{ const previous = context . token ; try { context . token = token ; return await context . requestBinary ( 'GET' , path )} finally { context . token = previous }}
2026-10-21 21:30:24 +08:00
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 )
2026-10-26 19:47:18 +08:00
if ( config . verifyLoadingDocuments ){
phase ( 'RESOURCE_PHOTO_ROUNDS_AND_FIVE_PER_CATEGORY' )
const read = ()=> send ( 'GET' , path + '/loading-documents' )
const first = await read (), firstRound = id ( first . reviewRound )
check ( 'approved trip evidence automatically imported' , first . photos . some ( p => p . slot === 'LOADING_SCENE' ) && first . photos . some ( p => p . slot === 'LOADING_LIST' ) && first . photos . every ( p =>[ 'LOADING_SCENE' , 'LOADING_LIST' ]. includes ( p . slot )))
const append = async ( slot , label , round , platform = false , replaceFileId )=>{
const base = ( platform ? path : pp ) + '/loading-documents' , token = platform ? context . token : picker . token , uploadKey = key ( 'UP-' + label )
const photo = await upload ( ` ${ base } / ${ slot } /uploads?idempotencyKey= ${ uploadKey } &reviewRound= ${ round } ` , ` ${ config . runId } -TEST- ${ label } .png` , token )
const current = await read (), body = { recordVersion : current . recordVersion , idempotencyKey : key ( 'SAVE-' + label ), fileId : id ( photo . fileId ), uploadKey ,...( replaceFileId ? { replaceFileId } : {})}
return { photo , body , base , token }
}
for ( const slot of slots . slice ( 2 )){
const p = await append ( slot , 'BEFORE-' + slot , firstRound )
await send ( 'PUT' , ` ${ p . base } / ${ slot } ` , p . body , p . token )
}
const pending = await append ( 'PRE_DEPARTURE_CHECK' , 'OLD-PENDING' , firstRound )
const beforeChange = await read (), oldFiles = new Set ( beforeChange . photos . map ( p => id ( p . fileId )))
let detail = await send ( 'GET' , path )
check ( 'replacement resources differ from original' , id ( detail . vehicleInfo . vehicle . vehicleId ) !== config . replacementVehicleId && id ( detail . vehicleInfo . driver . driverId ) !== config . replacementDriverId )
const vehicleArchive = await send ( 'GET' , ` ${ ROOT } /fleet/resource-photos/VEHICLE/ ${ config . replacementVehicleId } ` )
const driverArchive = await send ( 'GET' , ` ${ ROOT } /fleet/drivers/ ${ config . replacementDriverId } ` )
const driverPortrait = await send ( 'GET' , ` ${ ROOT } /fleet/resource-photos/DRIVER/ ${ config . replacementDriverId } ` )
assert ( vehicleArchive . photo ? . fileId && driverArchive . licenseFileId && driverPortrait . photo ? . fileId , 'Replacement resources require maintained vehicle photo, approved driver license and driver portrait' )
await send ( 'POST' , path + '/return-to-review' ,{ recordVersion : detail . recordVersion , idempotencyKey : key ( 'RETURN' ), reason : 'TEST zero-pick resource evidence invalidation' })
detail = await send ( 'GET' , path )
let result = await send ( 'PUT' , path ,{ recordVersion : detail . recordVersion , idempotencyKey : key ( 'SWAP' ), maintenance : detail . vehicleInfo . maintenance ,
vehicle : { mode : 'MASTER' , vehicleId : config . replacementVehicleId }, driver : { mode : 'MASTER' , driverId : config . replacementDriverId , evidence : { expectedTaskEndAt : report . trip . expectedDriverEndAt }},
plannedDepartureAt : report . trip . updateRequest . plannedDepartureAt , estimatedArrivalAt : report . trip . updateRequest . estimatedArrivalAt , remark : 'TEST replacement with maintained archives' })
for ( const action of [ 'submit-review' , 'dispatch-review' , 'loading-review' ]) result = await send ( 'POST' , ` ${ path } / ${ action } ` ,{ recordVersion : result . recordVersion , idempotencyKey : key ( 'SECOND-' + action ),...( action === 'submit-review' ? { remark : 'TEST second review' } : { decision : 'APPROVED' , reason : 'TEST second review' })})
const second = await read (), round = id ( second . reviewRound )
check ( 'new review round invalidates all old resource evidence' , Number ( round ) > Number ( firstRound ) && second . photos . length === 2 && second . photos . every ( p => ! oldFiles . has ( id ( p . fileId ))) && [ 'LOADING_SCENE' , 'LOADING_LIST' ]. every ( s => second . photos . some ( p => p . slot === s )))
const sourcePaths = { LOADING_SCENE : ` ${ ROOT } /fleet/drivers/ ${ config . replacementDriverId } /license/ ${ driverArchive . licenseFileId } ` , LOADING_LIST : ` ${ ROOT } /fleet/resource-photos/VEHICLE/ ${ config . replacementVehicleId } / ${ vehicleArchive . photo . fileId } /content` }
report . importedArchives = []
for ( const photo of second . photos ){
const original = await context . requestBinary ( 'GET' , sourcePaths [ photo . slot ])
const copied = await context . requestBinary ( 'GET' , ` ${ path } /loading-documents/ ${ photo . slot } /files/ ${ photo . fileId } /content` )
const pdaCopy = await binary ( ` ${ pp } /loading-documents/ ${ photo . slot } /files/ ${ photo . fileId } /content` , picker . token )
report . importedArchives . push ({ slot : photo . slot , fileId : photo . fileId , contentType : copied . contentType , sourceHttp : original . http , copyHttp : copied . http , pdaHttp : pdaCopy . http , sha256 : hash ( copied . bytes ), requestId : copied . requestId , traceId : copied . traceId })
check ( 'maintained archive copied exactly ' + photo . slot , original . http === 200 && copied . http === 200 && pdaCopy . http === 200 && original . bytes . length > 0 && hash ( original . bytes ) === hash ( copied . bytes ) && hash ( original . bytes ) === hash ( pdaCopy . bytes ))
}
await send ( 'PUT' , ` ${ pending . base } /PRE_DEPARTURE_CHECK` ,{... pending . body , recordVersion : second . recordVersion }, pending . token , true )
check ( 'stale upload bind preserves current photos' , JSON . stringify ( await read ()) === JSON . stringify ( second ))
const stale = await context . requestMultipart ( 'POST' , ` ${ pp } /loading-documents/PRE_DEPARTURE_CHECK/uploads?idempotencyKey= ${ key ( 'STALE-ROUND' ) } &reviewRound= ${ firstRound } ` ,{ name : 'TEST-stale-round.png' , type : 'image/png' , content : image }, picker . token )
check ( 'stale review round upload rejected' , id ( stale . body ? . code ) === 'A0409' )
for ( const [ slotIndex , slot ] of slots . entries ()){
let docs = await read (), count = docs . photos . filter ( p => p . slot === slot ). length
for ( let index = count ; index < 5 ; index ++ ){
const p = await append ( slot , ` ${ slot } - ${ index } ` , round ,( index + slotIndex ) % 2 === 0 )
await send ( 'PUT' , ` ${ p . base } / ${ slot } ` , p . body , p . token )
}
docs = await read (); check ( 'five saved ' + slot , docs . photos . filter ( p => p . slot === slot ). length === 5 )
const excess = await append ( slot , 'EXCESS-' + slot , round )
const rejected = await context . request ( 'PUT' , ` ${ excess . base } / ${ slot } ` , excess . body , excess . token ,{})
check ( 'sixth rejected ' + slot ,[ 'A0400' , 'A0409' ]. includes ( id ( rejected . body ? . code )))
check ( 'sixth rejection preserves version ' + slot , JSON . stringify ( await read ()) === JSON . stringify ( docs ))
const replace = await append ( slot , 'REPLACE-' + slot , round , true , id ( docs . photos . find ( p => p . slot === slot ). fileId ))
const replaced = await send ( 'PUT' , ` ${ replace . base } / ${ slot } ` , replace . body , replace . token )
const replay = await send ( 'PUT' , ` ${ replace . base } / ${ slot } ` , replace . body , replace . token )
check ( 'full category replacement and replay ' + slot , replaced . photos . filter ( p => p . slot === slot ). length === 5 && replaced . photos . some ( p => id ( p . fileId ) === id ( replace . photo . fileId )) && ! replaced . photos . some ( p => id ( p . fileId ) === replace . body . replaceFileId ) && JSON . stringify ( replay ) === JSON . stringify ( replaced ))
}
const adminDocs = await read (), pdaDocs = await send ( 'GET' , pp + '/loading-documents' , undefined , picker . token )
check ( 'Admin and PDA share all 25 photos' , adminDocs . photos . length === 25 && adminDocs . complete && JSON . stringify ( adminDocs ) === JSON . stringify ( pdaDocs ))
report . sharedFiles = []
for ( const photo of adminDocs . photos ){
const admin = await context . requestBinary ( 'GET' , ` ${ path } /loading-documents/ ${ photo . slot } /files/ ${ photo . fileId } /content` )
const pda = await binary ( ` ${ pp } /loading-documents/ ${ photo . slot } /files/ ${ photo . fileId } /content` , picker . token )
report . sharedFiles . push ({ slot : photo . slot , fileId : photo . fileId , adminHttp : admin . http , pdaHttp : pda . http , adminSha256 : hash ( admin . bytes ), pdaSha256 : hash ( pda . bytes ), requestId : pda . requestId , traceId : pda . traceId })
check ( 'Admin and PDA file bytes ' + photo . slot + ' ' + photo . fileId , admin . http === 200 && pda . http === 200 && hash ( admin . bytes ) === hash ( image ) && hash ( pda . bytes ) === hash ( image ))
}
report . loadingDocumentVerification = { reviewRound : round , photos : 25 , replacementVehicleId : config . replacementVehicleId , replacementDriverId : config . replacementDriverId }
}
2026-10-21 21:30:24 +08:00
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 }
2026-10-26 19:47:18 +08:00
// 新实收库存仍须走当前仓租预校验,不能用旧脚本绕过正式扫码前置。
let preparation = await send ( 'POST' , PDA + '/storage-rent/preparations' ,{ mode : 'DISPATCH' , businessNo : report . dispatchNo , itemId : m . dispatchItemId , positionId : m . positionId , packages : 1 , productQuantity : 1 , idempotencyKey : body . idempotencyKey , cartonMarks : []}, picker . token )
for ( let attempt = 0 ; ! preparation . ready && preparation . status === 'FREEZE_PENDING' && attempt < 20 ; attempt ++ ){ await new Promise ( resolve => setTimeout ( resolve , 500 )); preparation = await send ( 'GET' , PDA + '/storage-rent/preparations/' + preparation . preparationId , undefined , picker . token )}
check ( 'storage rent ready before pick ' + index , preparation . ready === true )
2026-10-21 21:30:24 +08:00
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*$/ )
2026-10-26 19:47:18 +08:00
if ( ! config . verifyLoadingDocuments ) 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 all saved photos' , docs . complete && docs . photos . length === initialDocuments . photos . length + ( config . verifyLoadingDocuments ? 0 : 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 }}