test(dispatch): verify multi-photo evidence and synchronize PDF storage assertions

This commit is contained in:
2026-10-26 19:47:18 +08:00
parent a5befc9bad
commit 3e0d8d6c88
3 changed files with 94 additions and 5 deletions
+83 -3
View File
@@ -20,13 +20,16 @@ export function parseFlowArguments(args) {
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'}[args[i]]
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
}
@@ -69,6 +72,8 @@ export async function runLoadingFlow(config, env=process.env) {
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
@@ -130,9 +135,84 @@ export async function runLoadingFlow(config, env=process.env) {
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)}
@@ -142,8 +222,8 @@ export async function runLoadingFlow(config, env=process.env) {
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*$/)
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)
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')