test(lastleg): verify hold cycles against immutable receipt stock

This commit is contained in:
2026-10-03 02:12:42 +08:00
parent 375e355faf
commit dc05eb3f8a
2 changed files with 335 additions and 29 deletions
@@ -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) {