Files
baiyue-firstleg/scripts/dispatch-loading-flow-test-env.mjs
T

247 lines
28 KiB
JavaScript

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
else if(args[i]==='--stop-after-draft')config.stopAfterDraft=true
else if(args[i]==='--verify-loading-documents')config.verifyLoadingDocuments=true
else {
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]]
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')
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')
if(config.stopAfterDraft && config.resumeReport)throw Error('Draft UI preparation requires a fresh attempt')
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
}
// 二进制助手使用当前会话;串行切换后立即恢复,确保实际覆盖 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}}
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
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')}
}
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)
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)
}else report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId)
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}
}
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}
// 新实收库存仍须走当前仓租预校验,不能用旧脚本绕过正式扫码前置。
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)
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')
const initialDocuments=await send('GET',pp+'/loading-documents',undefined,picker.token),reviewRound=id(initialDocuments.reviewRound),uploadedFiles=new Set()
assert.match(reviewRound,/^[1-9]\d*$/)
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))
report.photos=[]
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)})}
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}}