test(lastleg): verify hold cycles against immutable receipt stock
This commit is contained in:
@@ -89,3 +89,140 @@ test('container conversion preserves long identifiers and generated pallets for
|
||||
assert.match(source, /AUTO_CONTAINER_PLANS_READY/)
|
||||
assert.match(source, /remain as marker-owned prerequisites consumed by overseas PDA receiving/)
|
||||
})
|
||||
|
||||
function holdModel(fault='') {
|
||||
const buckets={availablePackages:'4',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}
|
||||
const order={id:'9007199254740993',customerId:'C1',overseasWarehouseId:'WH1',inboundCode:'IN-1',currentStatus:'INBOUND_COMPLETED',detained:false,heldChargeable:false,version:4}
|
||||
let positions=[1,3].map((n,i)=>({id:String(21+i),balanceId:'20',customerId:'C1',overseasWarehouseId:'WH1',inboundOrderId:order.id,
|
||||
palletId:String(31+i),palletNo:`PALLET-${i}`,batchNo:'PLAN',totalPackages:String(n),availablePackages:String(n),holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}))
|
||||
const original=structuredClone(positions)
|
||||
const balance={id:'20',customerId:'C1',overseasWarehouseId:'WH1',inboundOrderId:order.id,inboundCode:'IN-1',totalPackages:'4',...buckets}
|
||||
const receipt=positions.map(row=>({customerId:'C1',orderId:order.id,inboundCode:'IN-1',inventoryBalanceId:'20',inventoryPositionId:row.id,receivedPackages:row.availablePackages}))
|
||||
const commands=new Map(),calls=[];let tick=0,cycle=0,other=false,replayRead=false,failedRead=false
|
||||
const context={marker:'RUN',token:'admin',createdResources:{
|
||||
phase4TailOrderControls:{marker:'RUN',representativeOrderId:order.id},
|
||||
phase4InboundProducerFixtures:{marker:'RUN',containers:[{fulfillmentType:'ONE_CLICK_DROPSHIP',tenantId:'C1',containerNo:'BOX'}]},
|
||||
phase4OverseasPda:{marker:'RUN',receivingCompletionEvidence:{booking:{status:'VERIFIED',proof:{passed:true},scope:{customerId:'C1',warehouseId:'WH1',containerNo:'BOX'},
|
||||
stages:{AFTER_COMPLETE:{captured:true,evidence:{balances:[structuredClone(balance)],positions:original,stockReceipts:receipt}}}}}}},
|
||||
addStep(){},assert(_m,name,_r,passed){assert.ok(passed,name)},apiSuccess(_m,name,r){assert.equal(r.body.code,'200',name);return r.body.data},
|
||||
async request(method,raw,body,_token,headers={}) {
|
||||
calls.push({method,path:raw,body:structuredClone(body),headers})
|
||||
const path=new URL(raw,'https://offline.invalid').pathname
|
||||
const result=data=>({http:200,body:{code:'200',data:structuredClone(data)}})
|
||||
const conflict=()=>({http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'})
|
||||
const counts=()=>{
|
||||
for(const field of ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']) balance[field]=String(positions.reduce((sum,row)=>sum+Number(row[field]),0))
|
||||
balance.updatedAt=String(tick)
|
||||
}
|
||||
if(method==='GET' && path.endsWith('/positions')) {
|
||||
if(replayRead && ['read failure','foreign hold'].includes(fault) && !failedRead){failedRead=true;if(fault==='foreign hold'){order.version++;order.holdReason='ANOTHER-OPERATION'}throw new Error('read failed')}
|
||||
return result(fault==='missing position'?positions.slice(1):positions.slice().reverse())
|
||||
}
|
||||
if(method==='GET' && path.includes('/inventory/balances/')) {counts();return result(balance)}
|
||||
if(method==='GET' && path.includes('/orders/')) return result({order})
|
||||
if(path.endsWith('/manual')) {if(fault==='ambiguous plan')return {http:0};return conflict()}
|
||||
if(path.includes('/orders/') && method==='PUT') {
|
||||
const key=headers['Idempotency-Key'],prior=commands.get(key)
|
||||
if(prior) {
|
||||
if(JSON.stringify(body)!==JSON.stringify(prior.body)) return conflict()
|
||||
replayRead=true
|
||||
if(fault==='replay drift'){positions[0].allocatedPackages='1';positions[0].availablePackages='0';tick++}
|
||||
return result(prior.response)
|
||||
}
|
||||
if(body.held) {
|
||||
cycle++
|
||||
if(!(fault==='second cycle no movement' && cycle===2))for(const row of positions){row.holdPackages=String(Number(row.holdPackages)+Number(row.availablePackages));row.availablePackages='0'}
|
||||
} else {
|
||||
for(const row of positions){const retained=other&&row.id==='22'&&fault!=='release steals independent'?1:0;row.availablePackages=String(Number(row.availablePackages)+Number(row.holdPackages)-retained);row.holdPackages=String(retained)}
|
||||
}
|
||||
order.detained=body.held;order.heldChargeable=body.chargeable;order.holdReason=body.held?body.reason:null;order.version++;tick++
|
||||
const response={orderId:order.id,held:body.held,chargeable:body.chargeable,reason:order.holdReason,version:order.version}
|
||||
commands.set(key,{body:structuredClone(body),response})
|
||||
if(fault==='ambiguous order hold' && body.held)return {http:0}
|
||||
return result(response)
|
||||
}
|
||||
if(method==='POST' && path.includes('/inventory/')) {
|
||||
const row=positions.find(p=>p.id===body.positionId),held=path.endsWith('/hold')
|
||||
row.availablePackages=String(Number(row.availablePackages)+(held?-1:1));row.holdPackages=String(Number(row.holdPackages)+(held?1:-1));other=held;tick++
|
||||
if(fault==='ambiguous independent hold' && held)return {http:0}
|
||||
return result({replayed:false})
|
||||
}
|
||||
throw new Error(`unexpected ${method} ${raw}`)
|
||||
}}
|
||||
if(fault==='stale marker')context.createdResources.phase4OverseasPda.marker='OLD'
|
||||
if(fault==='no evidence')delete context.createdResources.phase4OverseasPda.receivingCompletionEvidence
|
||||
if(fault==='wrong immutable customer')receipt[0].customerId='OTHER'
|
||||
if(fault==='unsafe bucket')balance.procurementPackages='9007199254740993'
|
||||
if(fault==='moved source')positions[1].palletNo='MOVED'
|
||||
if(fault==='not enough stock'){
|
||||
positions[1].availablePackages='1';positions[1].totalPackages='1'
|
||||
const e=context.createdResources.phase4OverseasPda.receivingCompletionEvidence.booking.stages.AFTER_COMPLETE.evidence
|
||||
e.positions[1].availablePackages='1';e.positions[1].totalPackages='1';e.stockReceipts[1].receivedPackages='1'
|
||||
e.balances[0].availablePackages='2';e.balances[0].totalPackages='2';balance.totalPackages='2'
|
||||
}
|
||||
return {context,calls,order,positions}
|
||||
}
|
||||
|
||||
test('a failed repeated verification clears previous success evidence before any write',async()=>{
|
||||
const {context,calls}=holdModel('no evidence')
|
||||
context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt=true
|
||||
context.createdResources.phase4TailOrderControls.holdInventoryProof={status:'VERIFIED'}
|
||||
await assert.rejects(verifyReceivedOrderHold(context))
|
||||
assert.equal(context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt,false)
|
||||
assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof,undefined)
|
||||
assert.ok(calls.every(call=>call.method==='GET'))
|
||||
})
|
||||
|
||||
test('fresh immutable IDs drive two full cycles and preserve independent HOLD across every raw position',async()=>{
|
||||
const {context,calls,order}=holdModel()
|
||||
await verifyReceivedOrderHold(context)
|
||||
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
|
||||
assert.equal(proof.status,'VERIFIED');assert.equal(proof.restored,true);assert.equal(order.version,8)
|
||||
assert.equal(proof.snapshots.C2_RELEASED.positions.find(row=>row.id==='22').holdPackages,1)
|
||||
assert.equal(proof.snapshots.AFTER.balances[0].availablePackages,4)
|
||||
assert.equal(calls.filter(c=>c.path.endsWith('/manual')).length,2)
|
||||
assert.ok(!calls.some(c=>/inventory\/(balances|summary)\?/.test(c.path)))
|
||||
assert.equal(calls.filter(c=>c.method==='POST'&&c.path.endsWith('/inventory/release')).length,1)
|
||||
})
|
||||
|
||||
for(const fault of ['stale marker','no evidence','wrong immutable customer','missing position','unsafe bucket','not enough stock','moved source']) {
|
||||
test(`missing or unsafe fresh hold prerequisite blocks all writes: ${fault}`,async()=>{
|
||||
const {context,calls}=holdModel(fault)
|
||||
await assert.rejects(verifyReceivedOrderHold(context))
|
||||
assert.ok(calls.every(c=>c.method==='GET'))
|
||||
})
|
||||
}
|
||||
for(const fault of ['second cycle no movement','release steals independent','replay drift']) {
|
||||
test(`inventory regression is detected and never declared verified: ${fault}`,async()=>{
|
||||
const {context}=holdModel(fault)
|
||||
await assert.rejects(verifyReceivedOrderHold(context))
|
||||
assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof.status,'FAILED')
|
||||
assert.notEqual(context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt,true)
|
||||
})
|
||||
}
|
||||
for(const fault of ['ambiguous independent hold','ambiguous order hold','ambiguous plan']) {
|
||||
test(`unknown mutation response never triggers a blind recovery release: ${fault}`,async()=>{
|
||||
const {context,calls}=holdModel(fault)
|
||||
await assert.rejects(verifyReceivedOrderHold(context))
|
||||
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
|
||||
assert.equal(proof.uncertainWrite,true);assert.equal(proof.restored,false)
|
||||
assert.match(proof.recoveryError,/禁止自动释放/)
|
||||
assert.ok(!calls.some(c=>c.path.endsWith('/inventory/release')||c.body?.held===false))
|
||||
})
|
||||
}
|
||||
test('read failure after confirmed hold recovers only the exact known order cycle and independent one box',async()=>{
|
||||
const {context,calls}=holdModel('read failure')
|
||||
await assert.rejects(verifyReceivedOrderHold(context),/read failed/)
|
||||
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
|
||||
assert.equal(proof.status,'FAILED');assert.equal(proof.restored,true)
|
||||
assert.equal(calls.filter(c=>c.body?.held===false).length,1)
|
||||
assert.equal(proof.snapshots.RECOVERED.balances[0].holdPackages,0)
|
||||
})
|
||||
|
||||
test('recovery refuses to release a concurrently changed order hold',async()=>{
|
||||
const {context,calls}=holdModel('foreign hold')
|
||||
await assert.rejects(verifyReceivedOrderHold(context),/read failed/)
|
||||
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
|
||||
assert.equal(proof.restored,false);assert.match(proof.recoveryError,/只恢复本轮/)
|
||||
assert.ok(!calls.some(c=>c.path.endsWith('/inventory/release')||c.body?.held===false))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user