fix(dispatch): invalidate resource evidence and bind uploads to review rounds

This commit is contained in:
2026-10-26 19:21:11 +08:00
parent f6d9991955
commit a5befc9bad
15 changed files with 138 additions and 51 deletions
@@ -50,7 +50,7 @@ function model(fault = '') {
}
return response({...prior.result,replayed:true})
}
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip)
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()})
if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') {
@@ -87,7 +87,7 @@ function fixture(fault = '', nativeMode = false) {
if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) })
if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo })
if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops })
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1,
evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) })
if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) }
@@ -114,7 +114,7 @@ function fixture(fault = '') {
if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] })
if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops })
if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] })
if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2,
deliveryIndex: 1, ...(fault === 'missing-measurements' ? {} : { palletSpaces: 1, weightKg: 37, volumeCbm: 0.7, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] })
if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] })
@@ -20,7 +20,7 @@ const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo:
for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents fill missing slots without replacing existing files from ${initialStage}`, async () => {
const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [], replays = new Map()
const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', stage: initialStage,
const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', reviewRound: '1', stage: initialStage,
complete: false, editable: true, photos: [{ slot: LOADING_PHOTO_SLOTS[0], fileId: 'existing' }] }
let saved = false
const context = { async checkpoint() { saved = true },
@@ -28,7 +28,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents
async requestMultipart(method, path, file, token) {
assert.ok(saved); saved = false; assert.equal(token, 'PDA-TEST'); assert.equal(method, 'POST')
const slot = path.split('/').at(-2)
assert.match(path, /idempotencyKey=[a-f0-9]{64}$/); assert.equal(file.type, 'image/png')
assert.match(path, /idempotencyKey=[a-f0-9]{64}&reviewRound=1$/); assert.equal(file.type, 'image/png')
assert.equal(file.content.subarray(1, 4).toString(), 'PNG'); calls.push(slot)
assert.equal(proof.loadingDocuments.uploads.at(-1).status, 'PREPARED')
return ok({ slot, fileId: `new-${slot}` })
@@ -39,6 +39,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents
assert.equal(method, 'PUT')
if (replays.has(body.idempotencyKey)) return ok(replays.get(body.idempotencyKey))
assert.equal(body.recordVersion, state.recordVersion)
assert.equal(body.uploadKey, new URL(proof.loadingDocuments.uploads.at(-1).path, 'https://unused.test').searchParams.get('idempotencyKey'))
state.photos.push({ slot: path.split('/').at(-1), fileId: body.fileId })
state.recordVersion = String(BigInt(state.recordVersion) + 1n)
state.complete = state.photos.length === 5
@@ -54,7 +55,7 @@ for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents
for (const fault of ['checkpoint', 'upload-unknown', 'wrong-slot', 'replay-drift', 'early-departure']) test(`loading document ${fault} stops before loading confirmation`, async () => {
const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = []
const state = { dispatchNo: 'DP-A', stage: 'LOADING', recordVersion: '1', complete: false, editable: true, photos: [] }
const state = { dispatchNo: 'DP-A', stage: 'LOADING', reviewRound: '1', recordVersion: '1', complete: false, editable: true, photos: [] }
const context = { async checkpoint() { if (fault === 'checkpoint') throw Error('checkpoint failed') },
assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response?.body?.data },
async requestMultipart(_method, path) {
@@ -127,7 +128,7 @@ function server(fault = '') {
},
async request(method, path, body, token) {
calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' })
if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', reviewRound: '1', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) })
if (method === 'GET' && path.endsWith(`/master-data/vehicles/${VID}`)) return ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' })
if (method === 'GET' && path.includes(`/fleet/drivers/${DRIVER}?`)) return ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } })
if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown')
@@ -296,9 +296,10 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token)
const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token)
let documents = await read()
const initialStage = documents.stage
const reviewRound = id(documents.reviewRound)
const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId)))
const validate = value => {
if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage)
if (value.dispatchNo !== proof.dispatchNo || !/^[1-9]\d*$/.test(reviewRound) || id(value.reviewRound) !== reviewRound || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage)
|| value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配')
}
validate(documents)
@@ -307,7 +308,8 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token)
if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue
if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力')
const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`,
const uploadKey = key(proof, `PHOTO_UPLOAD_${slot}`)
const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,
name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' }
if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发')
proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.()
@@ -319,7 +321,7 @@ export async function ensureFleetLoadingDocuments(context, module, proof, token)
await context.checkpoint?.()
const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车')
const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) }
const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId), uploadKey }
const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token)
validate(bound)
check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
+5 -3
View File
@@ -140,10 +140,12 @@ export async function runLoadingFlow(config, env=process.env) {
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')
for(const slot of slots){const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${key('UPLOAD-'+slot)}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId)},picker.token)}
const docs=await send('GET',path+'/loading-documents');check('Admin sees five photos',docs.complete && docs.photos.length===5)
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 && hash(r.bytes)===hash(image));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})}
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')