diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 407f4d12c..6ae787023 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -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)) +}) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index fb81380b1..133fa0f2b 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -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) {