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
@@ -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) {