fix(dispatch): confirm scanned loading without repeated measurements

This commit is contained in:
2026-10-21 18:44:26 +08:00
parent 6088ff10c2
commit 538a63c635
20 changed files with 105 additions and 73 deletions
@@ -70,11 +70,11 @@ function model(fault = '') {
if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'}
return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平')
}
assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2)
assert.equal(body.recordVersion,nativeVersion); assert.equal('measurements' in body,false)
stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT'
for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'OUTBOUNDED',packageCount:1};s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:fault==='partial departure'&&i===1?'WAIT_DELIVERY':'DELIVERING',version:0}} }
if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'}
confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'}
confirmedLoad={manifestHash:body.manifestHash,pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletSpaces:1,weightKg:100,volumeCbm:1,deliveryIndex:i+1})),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'}
return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE')
}
if (path.endsWith('/pick')) {
@@ -119,13 +119,13 @@ test('current DTO-shaped two-customer trip rejects unready gate, departs once an
assert.ok(!m.calls.some(c=>c.raw.endsWith('/complete')||c.raw.includes('/dispatches/bind')))
})
test('missing physical pallet-space evidence blocks after both real picks without fabricating loading or delivery',async()=>{
test('scanned pallets confirm without a separate pallet-space measurement receipt',async()=>{
const m=model();delete m.fixture.loadingMeasurements
await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/palletSpaces/)
assert.ok(m.state.every(row=>row.picked===1&&row.delivery===null))
assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1,'only the earlier expected unready rejection was sent')
assert.equal(m.calls.some(call=>call.raw.endsWith('/transfer-delivery')),false)
await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory})
assert.ok(m.state.every(row=>row.picked===1&&row.delivery!==null))
assert.ok(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).every(call=>!('measurements' in call.body)))
})
test('explicit rollback verifier runs after both picks and before successful departure',async()=>{
const m=model();let calls=0
const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory,
@@ -169,7 +169,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
&& canonical(priorProof.members) === canonical(proof.members)
&& priorProof.requests.every(row => row.method === 'GET' || row.status === 'RESPONDED')
&& Number(prior?.dispatch?.counts?.pickedPackageCount) === 2 && id(command?.body?.recordVersion) === id(prior?.dispatch?.recordVersion)
&& canonical(command?.body?.measurements) === canonical(command?.result?.result?.confirmedLoad?.pallets)
&& canonical(fixture.tripProof.loadingPreview?.pallets?.map(p=>({warehousePalletId:id(p.warehousePalletId),palletSpaces:1,weightKg:p.weightKg??null,volumeCbm:p.volumeCbm??null,deliveryIndex:p.deliveryIndex}))) === canonical(command?.result?.result?.confirmedLoad?.pallets)
&& prior?.dispatch?.dispatchNo === fixture.dispatchNo && prior.dispatch.stage === 'LOADING'
&& command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' && command.result.stage === 'DELIVERING'
&& command.result.dispatchNo === fixture.dispatchNo && command.result.idempotencyKey === command.body.idempotencyKey
@@ -200,7 +200,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep
&& rejectedBefore.dispatch.stage === 'LOADING' && rejectedBefore.task.pickingReconciled === false)
const preview = await request('GET', `${pdaPath}/loading`, undefined, 'Read incomplete native loading manifest', true)
await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'),
manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true,
manifestHash: preview.manifestHash, remark: context.marker }, 'Unready second customer blocks explicit loading', true,
message => /拣货.*核平|尚未核平/.test(message))
equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged')
proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED'
@@ -143,9 +143,9 @@ function server(fault = '') {
}
assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3)
assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST')
assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }])
assert.equal('measurements' in body, false)
loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM')
detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })
detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: [{ warehousePalletId: PALLET, palletSpaces: 1, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }] } })
}
if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) {
const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind')
@@ -313,7 +313,7 @@ test('PDA original-actor replay restores loading while another platform actor is
assert.equal(proof.loadingCommand.platformPositiveLoading, 'PENDING_SEPARATE_SCENARIO')
assert.deepEqual(proof.loadingCommand.beforeReplay, proof.loadingCommand.afterReplay)
assert.equal(proof.loadingCommand.platformReplay, undefined)
assert.equal(writes[0].body.measurements[0].weightKg, '187.25')
assert.equal('measurements' in writes[0].body, false)
assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path)))
const count = s.calls.length
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/)
@@ -347,10 +347,11 @@ for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`l
if (fault === 'edit-mutates-on-rejection') assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false)
})
test('missing actual producer measurement blocks before any edit rejection or loading write', async () => {
const s = server(), proof = await prepare(s); s.pick(); const count = s.calls.length
await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA'), /palletSpaces/)
assert.equal(s.calls.length, count + 1); assert.equal(s.calls.at(-1).method, 'GET')
test('scanned loading no longer requires the operator to supply a measurement receipt', async () => {
const s = server(), proof = await prepare(s); s.pick()
await confirmFleetLoading(s.context, 'native', proof, 'PDA')
assert.equal(proof.loadingCommand.result.stage, 'DELIVERING')
assert.equal('measurements' in proof.loadingCommand.body, false)
})
test('wrong-run producer receipt and unreconciled picking block loading without mutation', async () => {
@@ -228,11 +228,11 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu
|| preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`)
if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker
|| actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车')
const measurements = controlledPalletMeasurements(preview.pallets, proof.stops, actualMeasurements?.pallets)
const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex }))
await verifyPreDepartureReassignment(context, module, proof)
proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements
proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion,
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
if (beforeComplete) await beforeComplete(proof.loadingCommand)
const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken)
check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING'
@@ -240,8 +240,8 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu
const returned = confirmed.result.confirmedLoad.pallets
check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length
&& measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId
&& r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg)
&& decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex)))
&& r.palletSpaces === m.palletSpaces && (r.weightKg == null ? null : decimal(r.weightKg)) === (m.weightKg == null ? null : decimal(m.weightKg))
&& (r.volumeCbm == null ? null : decimal(r.volumeCbm)) === (m.volumeCbm == null ? null : decimal(m.volumeCbm)) && r.deliveryIndex === m.deliveryIndex)))
proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad
const snapshot = async label => ({
main: await command(context, module, 'GET', path, undefined, `${label}主单`),