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))
|
||||
})
|
||||
|
||||
@@ -334,42 +334,211 @@ async function convertContainerPlans(context, producer, details) {
|
||||
export async function verifyReceivedOrderHold(context) {
|
||||
const owned = context.createdResources.phase4TailOrderControls
|
||||
if (owned?.marker !== context.marker || !owned.representativeOrderId) throw new Error('缺少本轮暂扣目标订单')
|
||||
owned.holdVerifiedAfterReceipt = false
|
||||
delete owned.holdInventoryProof
|
||||
const producer = targetProducer(context)
|
||||
const loaded = await detail(context, { id: owned.representativeOrderId, customerId: producer?.tenantId }, '入库后读取本轮暂扣目标')
|
||||
const representative = loaded.data?.order
|
||||
if (!success(loaded.response) || id(representative?.id) !== id(owned.representativeOrderId)
|
||||
|| id(representative?.customerId) !== id(producer?.tenantId)
|
||||
|| representative?.currentStatus !== 'INBOUND_COMPLETED') throw new Error('暂扣正向验收必须使用本轮已完成入库订单')
|
||||
const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, {
|
||||
customerId: id(representative.customerId),
|
||||
})
|
||||
const holdKey = `${context.marker}-tail-hold`.slice(0, 128)
|
||||
const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) }
|
||||
const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
||||
context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response,
|
||||
held.data?.held === true && held.data?.chargeable === true
|
||||
&& id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1,
|
||||
trace(held.response), OPTIONS)
|
||||
if (!success(held.response)) return
|
||||
const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey })
|
||||
context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response,
|
||||
JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS)
|
||||
const heldConflict = await context.request('PUT', orderPath, { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, context.token, { 'Idempotency-Key': holdKey })
|
||||
context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS)
|
||||
const require = (name, passed) => {
|
||||
context.assert(MODULE, name, null, Boolean(passed), `orderId=${representative.id}`, OPTIONS)
|
||||
if (!passed) throw new Error(name)
|
||||
}
|
||||
const customerId = id(representative.customerId), orderId = id(representative.id)
|
||||
const attemptKey = `${context.marker}-V${representative.version}`
|
||||
const pda = context.createdResources.phase4OverseasPda
|
||||
require('暂扣库存前置必须为本轮已验证收货证据', pda?.marker === context.marker)
|
||||
const matches = Object.values(pda?.receivingCompletionEvidence || {}).filter(value => value.status === 'VERIFIED'
|
||||
&& value.proof?.passed === true && id(value.scope?.customerId) === customerId
|
||||
&& value.scope?.containerNo === producer.containerNo && value.stages?.AFTER_COMPLETE?.captured === true
|
||||
&& value.stages.AFTER_COMPLETE.evidence?.stockReceipts?.some(row => id(row.orderId) === orderId))
|
||||
require('暂扣目标必须唯一命中本轮不可变实收事实', matches.length === 1)
|
||||
const evidence = matches[0].stages.AFTER_COMPLETE.evidence
|
||||
const warehouseId = id(matches[0].scope.warehouseId)
|
||||
require('暂扣目标实际仓与收货证据一致', warehouseId && warehouseId === id(representative.overseasWarehouseId))
|
||||
const receipts = evidence.stockReceipts.filter(row => id(row.orderId) === orderId)
|
||||
require('暂扣实收事实具有完整客户来源和原始库存标识', receipts.length > 0
|
||||
&& receipts.every(row => id(row.customerId) === customerId && row.inboundCode === representative.inboundCode
|
||||
&& id(row.inventoryBalanceId) && id(row.inventoryPositionId)))
|
||||
const balanceIds = [...new Set(receipts.map(row => id(row.inventoryBalanceId)))].sort()
|
||||
const positionIds = [...new Set(receipts.map(row => id(row.inventoryPositionId)))].sort()
|
||||
require('暂扣收货证据包含原始余额与位置全集', Array.isArray(evidence.balances) && Array.isArray(evidence.positions))
|
||||
const originalBalances = evidence.balances.filter(row => id(row.inboundOrderId) === orderId)
|
||||
const originalPositions = evidence.positions.filter(row => balanceIds.includes(id(row.balanceId)))
|
||||
require('暂扣原始ID不得由公共SKU汇总推导或漏掉位置', sameHoldFacts(originalBalances.map(row=>id(row.id)),balanceIds)
|
||||
&& sameHoldFacts(originalPositions.map(row=>id(row.id)),positionIds)
|
||||
&& originalBalances.every(row=>id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId)
|
||||
&& originalPositions.every(row=>id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId))
|
||||
const proof = owned.holdInventoryProof = { marker:context.marker, orderId, customerId, warehouseId,
|
||||
balanceIds, positionIds, snapshots:{}, status:'RUNNING', restored:false }
|
||||
const orderPath = query(`${ORDERS}/${encodeURIComponent(orderId)}/hold`, {customerId})
|
||||
const inventoryPath = '/api/platform/v1/last-leg/inventory'
|
||||
let uncertain = false, otherApplied = false, active = null, baseline, otherHeld, otherBody
|
||||
const request = async (method,name,path,body,headers={}) => {
|
||||
let response
|
||||
try { response = await context.request(method,path,body,context.token,headers) }
|
||||
catch(error) { if(method!=='GET') uncertain=true;throw error }
|
||||
context.addStep(MODULE,name,response,success(response),trace(response),OPTIONS)
|
||||
if (!success(response)) {
|
||||
if(method!=='GET' && (!response?.http || response.http>=500)) uncertain=true
|
||||
throw new Error(`${name}: ${trace(response)}`)
|
||||
}
|
||||
return response.body.data
|
||||
}
|
||||
const reject = async (name,path,body,headers,method) => {
|
||||
let response
|
||||
try { response=await context.request(method,path,body,context.token,headers) }
|
||||
catch(error) {uncertain=true;throw error}
|
||||
if(!response?.http || response.http>=500 || success(response)) uncertain=true
|
||||
context.addStep(MODULE,name,response,conflict(response),trace(response),OPTIONS)
|
||||
require(`${name}必须明确拒绝`,conflict(response) && nonBlank(response.requestId) && nonBlank(response.traceId))
|
||||
return response
|
||||
}
|
||||
const read = async label => {
|
||||
const order = (await request('GET','暂扣回查订单事实',query(`${ORDERS}/${orderId}`,{customerId}))).order
|
||||
require('暂扣回查订单身份和入库状态稳定', id(order?.id)===orderId && id(order.customerId)===customerId
|
||||
&& id(order.overseasWarehouseId)===warehouseId && order.currentStatus==='INBOUND_COMPLETED')
|
||||
const balances=[],positions=[]
|
||||
for(const balanceId of balanceIds) {
|
||||
const balance=await request('GET','暂扣读取原始余额详情',query(`${inventoryPath}/balances/${balanceId}`,{customerId}))
|
||||
require('暂扣原始余额归属正确',id(balance?.id)===balanceId && id(balance.inboundOrderId)===orderId
|
||||
&& id(balance.customerId)===customerId && id(balance.overseasWarehouseId)===warehouseId)
|
||||
balances.push(holdNumbers(balance,HOLD_BALANCE_BUCKETS))
|
||||
const rows=await request('GET','暂扣读取原始余额所有位置',query(`${inventoryPath}/balances/${balanceId}/positions`,{customerId}))
|
||||
require('暂扣位置归属正确',Array.isArray(rows) && rows.every(row=>id(row.balanceId)===balanceId
|
||||
&& id(row.inboundOrderId)===orderId && id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId))
|
||||
positions.push(...rows.map(row=>holdNumbers(row,HOLD_POSITION_BUCKETS)))
|
||||
}
|
||||
require('暂扣位置集合完整且无重复',sameHoldFacts(positions.map(row=>id(row.id)),positionIds))
|
||||
for(const balance of balances) for(const field of HOLD_POSITION_BUCKETS) {
|
||||
require(`暂扣余额位置核平${field}`,balance[field]===positions.filter(row=>id(row.balanceId)===id(balance.id)).reduce((sum,row)=>sum+row[field],0))
|
||||
}
|
||||
const value={order,balances,positions};proof.snapshots[label]=value;return value
|
||||
}
|
||||
const stock = value => ({balances:value.balances.map(holdStableStock),positions:value.positions.map(holdStableStock)})
|
||||
const expectStock = (actual,expected,label) => require(label,sameHoldFacts(stock(actual),stock(expected)))
|
||||
const orderCommand = async (held,version,suffix) => {
|
||||
const body={held,chargeable:held,reason:`${context.marker} ${suffix}`,expectedVersion:Number(version)}
|
||||
const headers={'Idempotency-Key':`${attemptKey}-${suffix}`}
|
||||
const data=await request('PUT',held?'暂扣本轮单票尾程订单':'解除本轮单票尾程暂扣',orderPath,body,headers)
|
||||
// A malformed successful response is not sufficient authority for a recovery mutation.
|
||||
if(id(data?.orderId)!==orderId || data.held!==held || Number(data.version)!==Number(version)+1) uncertain=true
|
||||
require('暂扣命令返回本订单预期版本及计费状态',!uncertain && data.chargeable===held && (held || data.reason==null))
|
||||
active=held?{version:Number(data.version),reason:body.reason}:null
|
||||
return {body,headers,data}
|
||||
}
|
||||
const releaseOther = async () => {
|
||||
await request('POST','释放本轮独立库存暂扣',`${inventoryPath}/release`,{...otherBody,
|
||||
idempotencyKey:`${attemptKey}-F32-OTHER-RELEASE`,remark:`${context.marker} independent release`})
|
||||
otherApplied=false
|
||||
}
|
||||
try {
|
||||
baseline=await read('BEFORE')
|
||||
require('暂扣前货物可用且无现存业务冻结或出库占用',baseline.order.detained===false
|
||||
&& baseline.positions.every(row=>row.holdPackages===0 && row.allocatedPackages===0 && row.pickedPackages===0 && row.receivingPackages===0)
|
||||
&& baseline.balances.every(row=>row.inTransitPackages===0 && row.procurementPackages===0))
|
||||
for(const row of baseline.balances) {
|
||||
const original=originalBalances.find(ref=>id(ref.id)===id(row.id))
|
||||
require('暂扣当前余额仍等于本轮实收入账',HOLD_BALANCE_BUCKETS.every(field=>row[field]===holdInteger(original[field])))
|
||||
}
|
||||
for(const row of baseline.positions) {
|
||||
const original=originalPositions.find(ref=>id(ref.id)===id(row.id))
|
||||
require('暂扣原始位置仍绑定实收托盘库位批次', ['balanceId','palletId','palletNo','batchNo','warehouseLocationId','warehouseLocationCode','destinationWarehouseCode']
|
||||
.every(field=>id(row[field])===id(original[field]))
|
||||
&& receipts.filter(ref=>id(ref.inventoryPositionId)===id(row.id)).every(ref=>id(ref.inventoryBalanceId)===id(row.balanceId)))
|
||||
require('暂扣当前位置仍等于本轮实收入账',HOLD_POSITION_BUCKETS.every(field=>row[field]===holdInteger(original[field]))
|
||||
&& row.availablePackages===receipts.filter(ref=>id(ref.inventoryPositionId)===id(row.id)).reduce((sum,ref)=>sum+holdInteger(ref.receivedPackages),0))
|
||||
}
|
||||
const selected=baseline.positions.find(row=>row.availablePackages>=2)
|
||||
require('暂扣必须有足够独立实收库存保留另一业务一箱',Boolean(selected))
|
||||
otherBody={positionId:selected.id,packages:1,businessOrderNo:`${context.marker}-F32-OTHER`,
|
||||
sourceEventId:`${context.marker}-F32-OTHER`,idempotencyKey:`${attemptKey}-F32-OTHER-HOLD`,remark:`${context.marker} independent hold`}
|
||||
await request('POST','建立本轮独立库存暂扣一箱',`${inventoryPath}/hold`,otherBody);otherApplied=true
|
||||
otherHeld=shiftHoldStock(baseline,selected.id,false)
|
||||
expectStock(await read('OTHER_HELD'),otherHeld,'独立暂扣只移动目标位置一箱且所有桶守恒')
|
||||
const allHeld=shiftHoldStock(otherHeld,null,true)
|
||||
for(let cycle=1;cycle<=2;cycle++) {
|
||||
const before=await read(`C${cycle}_BEFORE`)
|
||||
const held=await orderCommand(true,before.order.version,`F32-C${cycle}-HOLD`)
|
||||
const during=await read(`C${cycle}_HELD`)
|
||||
require('暂扣只推进单票版本并保留计费原因',during.order.detained===true && Number(during.order.version)===Number(held.data.version)
|
||||
&& during.order.holdReason===held.body.reason && during.order.heldChargeable===true)
|
||||
expectStock(during,allHeld,`第${cycle}轮暂扣全部原始位置可用转HOLD`)
|
||||
await request('PUT','暂扣命令同键同载荷重放',orderPath,held.body,held.headers)
|
||||
require('暂扣重放版本及全部库存桶零漂移',sameHoldFacts(await read(`C${cycle}_HOLD_REPLAY`),during))
|
||||
await reject('暂扣同键异载荷返回A0409',orderPath,{...held.body,reason:`${held.body.reason}-DIFFERENT`},held.headers,'PUT')
|
||||
require('暂扣异载荷拒绝且事实不变',sameHoldFacts(await read(`C${cycle}_CONFLICT`),during))
|
||||
await reject('暂扣尾程订单不能生成人工计划',`${PLANS}/manual`,manualPlanBody(representative,held.data.version,context.marker),
|
||||
{'Idempotency-Key':`${attemptKey}-F32-C${cycle}-PLAN`},'POST')
|
||||
require('人工计划拒绝后暂扣与库存不变',sameHoldFacts(await read(`C${cycle}_PLAN_REJECTED`),during))
|
||||
const released=await orderCommand(false,during.order.version,`F32-C${cycle}-RELEASE`)
|
||||
const after=await read(`C${cycle}_RELEASED`)
|
||||
require('解除暂扣清空计费与原因并推进版本',after.order.detained===false && after.order.heldChargeable===false
|
||||
&& after.order.holdReason==null && Number(after.order.version)===Number(released.data.version))
|
||||
expectStock(after,otherHeld,`第${cycle}轮解除保留另一业务HOLD一箱`)
|
||||
await request('PUT','解除暂扣原键重放',orderPath,released.body,released.headers)
|
||||
require('解除重放订单余额及所有位置零漂移',sameHoldFacts(await read(`C${cycle}_RELEASE_REPLAY`),after))
|
||||
}
|
||||
await releaseOther()
|
||||
const final=await read('AFTER')
|
||||
expectStock(final,baseline,'最终库存完整恢复到本轮实收入账基线')
|
||||
require('两轮暂扣后订单未冻结且版本精确增加四',final.order.detained===false && Number(final.order.version)===Number(baseline.order.version)+4)
|
||||
proof.restored=true;proof.status='VERIFIED';owned.holdVerifiedAfterReceipt=true
|
||||
} catch(error) {proof.status='FAILED';proof.error=error.message;throw error}
|
||||
finally {
|
||||
proof.uncertainWrite=uncertain
|
||||
if(proof.status==='FAILED' && (uncertain || active || otherApplied)) {
|
||||
try {
|
||||
if(uncertain) throw new Error('写入响应不明,保留样本,禁止自动释放')
|
||||
const current=await read('RECOVERY_BEFORE')
|
||||
if(active) {
|
||||
require('只恢复本轮已确认且版本原因一致的暂扣',current.order.detained===true
|
||||
&& Number(current.order.version)===active.version && current.order.holdReason===active.reason)
|
||||
await orderCommand(false,active.version,'F32-RECOVERY-RELEASE')
|
||||
} else require('恢复独立冻结前订单必须未暂扣',current.order.detained===false)
|
||||
if(otherApplied) {
|
||||
// Never release one arbitrary HOLD: all buckets must still equal our known independent hold.
|
||||
expectStock(await read('RECOVERY_OTHER_CHECK'),otherHeld,'恢复只释放本轮已确认独立一箱')
|
||||
await releaseOther()
|
||||
}
|
||||
const recovered=await read('RECOVERED');expectStock(recovered,baseline,'失败恢复后库存完整回到原基线')
|
||||
require('失败恢复后订单未暂扣',recovered.order.detained===false);proof.restored=true
|
||||
} catch(recoveryError) {proof.recoveryError=recoveryError.message}
|
||||
proof.uncertainWrite=uncertain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker)
|
||||
const blockedPlan = await context.request('POST', `${PLANS}/manual`, blockedPlanBody, context.token, { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) })
|
||||
context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS)
|
||||
|
||||
const releaseKey = `${context.marker}-tail-release`.slice(0, 128)
|
||||
const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) }
|
||||
const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey })
|
||||
context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response,
|
||||
released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null
|
||||
&& Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS)
|
||||
if (!success(released.response)) return
|
||||
|
||||
owned.holdVerifiedAfterReceipt = true
|
||||
const HOLD_POSITION_BUCKETS=['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']
|
||||
const HOLD_BALANCE_BUCKETS=[...HOLD_POSITION_BUCKETS,'inTransitPackages','procurementPackages']
|
||||
function holdInteger(value) {
|
||||
if(!((typeof value==='number' || (typeof value==='string' && /^(0|[1-9][0-9]*)$/.test(value)))
|
||||
&& Number.isSafeInteger(Number(value)) && Number(value)>=0)) throw new Error('暂扣库存数量不是非负安全整数')
|
||||
return Number(value)
|
||||
}
|
||||
function holdNumbers(row,fields) {
|
||||
const value={...row}
|
||||
for(const field of [...fields,'totalPackages']) value[field]=holdInteger(row[field])
|
||||
if(value.totalPackages!==fields.reduce((sum,field)=>sum+value[field],0)) throw new Error('暂扣库存总量与完整数量桶不守恒')
|
||||
return value
|
||||
}
|
||||
function holdStableStock({updatedAt,version,...row}) {return row}
|
||||
function sameHoldFacts(a,b) {
|
||||
const normalize=value=>Array.isArray(value)?value.map(normalize).sort((x,y)=>JSON.stringify(x).localeCompare(JSON.stringify(y)))
|
||||
:value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value
|
||||
return JSON.stringify(normalize(a))===JSON.stringify(normalize(b))
|
||||
}
|
||||
function shiftHoldStock(source,positionId,all) {
|
||||
const value=structuredClone(source)
|
||||
for(const position of value.positions) {
|
||||
const quantity=all?position.availablePackages:id(position.id)===id(positionId)?1:0
|
||||
position.availablePackages-=quantity;position.holdPackages+=quantity
|
||||
const balance=value.balances.find(row=>id(row.id)===id(position.balanceId))
|
||||
balance.availablePackages-=quantity;balance.holdPackages+=quantity
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
|
||||
Reference in New Issue
Block a user