167 lines
18 KiB
JavaScript
167 lines
18 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 {
|
|
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')
|
|
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
|
|
}
|
|
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)
|
|
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')
|
|
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)
|
|
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}}
|