test(lastleg): verify hold cycles against immutable receipt stock
This commit is contained in:
@@ -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