test(fulfillment): reconcile frozen PDA sources and dispatch departure gate

This commit is contained in:
2026-09-30 13:50:13 +08:00
parent 13028d8a5a
commit 041a27c325
2 changed files with 248 additions and 66 deletions
@@ -101,53 +101,112 @@ export function inventoryQuantitySnapshot(rows) {
])).sort((a, b) => a.id.localeCompare(b.id))
}
export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) {
const balanceDimension = row => JSON.stringify([id(row.customerId), id(row.overseasWarehouseId),
id(row.destinationWarehouseId), id(row.destinationWarehouseCode), id(row.skuCode)])
const validBuckets = (row, fields) => fields.every(field => row[field] !== null && row[field] !== undefined
&& row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0)
export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name, sourceBalanceIds = []) {
const codes = new Set(inboundCodes)
const candidates = new Map()
for (const inboundCode of codes) {
const current = await allPages(context, `${LASTLEG_BASE}/inventory/balances`,
{ customerId, overseasWarehouseId: warehouseId, inboundCode }, `${name}-${inboundCode}`)
if (!current) return null
for (const balance of current) {
const owned = id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId
const quantities = BALANCE_BUCKETS.every(field => balance[field] !== null && balance[field] !== undefined && balance[field] !== '' && Number.isSafeInteger(Number(balance[field])) && Number(balance[field]) >= 0)
const previous = candidates.get(id(balance.id))
const stable = !previous || JSON.stringify(inventoryQuantitySnapshot([previous])) === JSON.stringify(inventoryQuantitySnapshot([balance]))
context.assert(MODULE, `${name}余额客户仓库数量及重复读取一致`, null, owned && quantities && stable,
`balanceId=${id(balance.id)}, owned=${owned}, quantities=${quantities}, stable=${stable}`, OPTIONS)
if (!owned || !quantities || !stable) return null
candidates.set(id(balance.id), balance)
}
const sourceIds = [...new Set(sourceBalanceIds.map(id))].sort()
if (!sourceIds.length || sourceIds.some(value => !/^[1-9][0-9]*$/.test(value))) {
blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源')
return null
}
const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`,
{ customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`)
if (!groups) return null
const groupValid = groups.every(row => id(row.customerId) === customerId
&& id(row.overseasWarehouseId) === warehouseId && validBuckets(row, BALANCE_BUCKETS))
&& new Set(groups.map(balanceDimension)).size === groups.length
context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS)
if (!groupValid) return null
context.context ||= {}
context.context.phase5InventorySources ||= {}
const frozen = context.context.phase5InventorySources
const snapshot = []
for (const balance of candidates.values()) {
const current = await platform(context, 'GET', `${name}读取余额实际全部位置`,
query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId }))
for (const balanceId of sourceIds) {
const sourceKey = `${customerId}:${warehouseId}:${balanceId}`
const detailResponse = await context.request('GET', query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}`, { customerId }))
const missingZero = detailResponse.http === 404 || (detailResponse.http === 200 && code(detailResponse) === 'A0404')
if (!success(detailResponse) && !missingZero) {
context.apiSuccess(MODULE, `${name}读取冻结真实余额`, detailResponse, trace(detailResponse), OPTIONS)
return null
}
const current = await platform(context, 'GET', `${name}读取冻结余额实际全部位置`,
query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { customerId }))
const positions = current.data
const valid = success(current.response) && Array.isArray(positions)
&& new Set(positions.map(row => id(row.id))).size === positions.length
&& positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === id(balance.id)
&& positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === balanceId
&& id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId
&& nonBlank(row.inboundCode)
&& POSITION_BUCKETS.every(field => row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0))
&& nonBlank(row.inboundCode) && validBuckets(row, POSITION_BUCKETS))
context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid,
`balanceId=${id(balance.id)}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS)
`balanceId=${balanceId}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS)
if (!valid) return null
let balance = detailResponse.body?.data
if (missingZero) {
const prior = frozen[sourceKey]
const currentGroup = prior && groups.find(row => balanceDimension(row) === balanceDimension(prior.balance))
const zeroProof = prior && positions.every(row => POSITION_BUCKETS.every(field => Number(row[field]) === 0))
&& ['inTransitPackages', 'procurementPackages'].every(field => Number(prior.balance[field]) === 0)
&& BALANCE_BUCKETS.every(field => Number(currentGroup?.[field] || 0)
<= Number(prior.group?.[field] || 0) - Number(prior.balance[field]))
context.assert(MODULE, `${name}零余额排除由原ID位置及完整分组共同证实`, detailResponse, Boolean(zeroProof),
`balanceId=${balanceId}, retainedSource=${Boolean(prior)}, zeroPositions=${positions.length}`, OPTIONS)
if (!zeroProof) return null
balance = { ...prior.balance, ...Object.fromEntries(BALANCE_BUCKETS.map(field => [field, 0])) }
} else {
const owned = id(balance?.id) === balanceId && id(balance?.customerId) === customerId
&& id(balance?.overseasWarehouseId) === warehouseId && validBuckets(balance, BALANCE_BUCKETS)
context.assert(MODULE, `${name}真实余额客户仓及完整数量有效`, detailResponse, owned, `balanceId=${balanceId}`, OPTIONS)
if (!owned) return null
const group = groups.find(row => balanceDimension(row) === balanceDimension(balance))
const included = Boolean(group) && BALANCE_BUCKETS.every(field => Number(group[field]) >= Number(balance[field]))
context.assert(MODULE, `${name}真实非零余额包含于独立完整分组`, detailResponse, included, `balanceId=${balanceId}`, OPTIONS)
if (!included) return null
frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] }
}
const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field]))
context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled,
`balanceId=${id(balance.id)}, balanceTotal=${balance.totalPackages}`, OPTIONS)
`balanceId=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS)
if (!reconciled) return null
// Balance DTO need not expose inboundCode. Text-filtered child tickets cannot count as this exact source.
const exact = positions.filter(row => codes.has(row.inboundCode))
if (!exact.length) continue
snapshot.push({ id: id(balance.id), customerId, overseasWarehouseId: warehouseId,
snapshot.push({ id: balanceId, customerId, overseasWarehouseId: warehouseId,
inboundCodes: [...new Set(exact.map(row => row.inboundCode))].sort(),
...Object.fromEntries(POSITION_BUCKETS.map(field => [field, exact.reduce((sum, row) => sum + Number(row[field]), 0)])),
balance: inventoryQuantitySnapshot([balance])[0],
positions: inventoryQuantitySnapshot(positions),
matchingPositionIds: exact.map(row => id(row.id)).sort() })
balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions),
matchingPositionIds: exact.map(row => id(row.id)).sort(), globalGroups: inventoryQuantitySnapshot(groups) })
}
return snapshot.sort((a, b) => a.id.localeCompare(b.id))
const jointlyIncluded = groups.every(group => BALANCE_BUCKETS.every(field => snapshot
.filter(row => balanceDimension(row.balance) === balanceDimension(group))
.reduce((sum, row) => sum + Number(row.balance[field]), 0) <= Number(group[field])))
context.assert(MODULE, `${name}固定原始余额合计不超过独立分组`, null, jointlyIncluded,
`rawBalances=${snapshot.length}, groups=${groups.length}`, OPTIONS)
return jointlyIncluded ? snapshot : null
}
export function dispatchInventorySources(task, dispatchNo, warehouseId) {
if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId
|| !Array.isArray(task.items) || !task.items.length) return null
const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : [])
if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null
const references = new Map()
for (const row of positions) {
const previous = references.get(id(row.id))
if (previous && previous !== id(row.balanceId)) return null
references.set(id(row.id), id(row.balanceId))
}
return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(),
sourcePositionIds: [...references.keys()].sort(),
sourcePositionBalances: [...references].map(([positionId, balanceId]) => ({ positionId, balanceId }))
.sort((left, right) => left.positionId.localeCompare(right.positionId)) }
}
export function inventorySourcesPresent(snapshot, references) {
return Array.isArray(snapshot) && references.every(reference => snapshot
.find(row => row.id === reference.balanceId)?.positions.some(position => position.id === reference.positionId
&& position.balanceId === reference.balanceId))
}
async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) {
@@ -166,6 +225,7 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp
}
const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0)
const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, row) => sum + Number(row.totalPackages), 0)
const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0)
const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right)
@@ -223,9 +283,14 @@ export async function completeReturnedException(context, { customerId, warehouse
return null
}
const source = sources[0]
const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存')
const sourceBalanceIds = owned.inventoryProof?.sourceBalanceIds || []
const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存', sourceBalanceIds)
const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo)
if (!stockBefore || !ledgerBefore) return null
if (!stockBefore.some(balance => balance.positions.some(position => position.id === id(source.inventoryPositionId)))) {
blocker(context, '退回原位置冻结归属', '原returnSource位置必须存在于正式排车冻结真实余额中')
return null
}
const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}`
const palletCode = continuing ? previousAttempt.targetPalletNo
: `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128)
@@ -278,13 +343,13 @@ export async function completeReturnedException(context, { customerId, warehouse
completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1
&& /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS)
if (!success(completed.response)) {
const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存')
const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存', sourceBalanceIds)
const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo)
context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response,
sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS)
return null
}
const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存')
const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存', sourceBalanceIds)
const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo)
const targetPositions = []
for (const balance of stockAfter || []) {
@@ -302,11 +367,12 @@ export async function completeReturnedException(context, { customerId, warehouse
context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null,
stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1
&& balanceQuantityTotal(stockAfter) - balanceQuantityTotal(stockBefore) === 1
&& groupQuantityTotal(stockAfter) - groupQuantityTotal(stockBefore) === 1
&& newRows?.length === 1 && newRows[0].deltaPackages === 1
&& newRows[0].afterPackages - newRows[0].beforePackages === 1,
`before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS)
const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody)
const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存')
const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存', sourceBalanceIds)
const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo)
context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response,
replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId)
@@ -551,6 +617,20 @@ function fixture(context) {
return { dispatch, receiving }
}
export async function verifyDepartureReadiness(context, token, dispatchNo) {
const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const passed = success(ready.response) && success(dispatch.response)
&& ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED'
&& ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo
&& dispatch.data?.status === 'READY_TO_DEPART'
context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response,
passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS)
return passed ? ready : null
}
export async function pickAll(context, token, dispatchNo) {
let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
@@ -726,31 +806,38 @@ export async function run(context) {
await disablePicker(context, picker, 5)
return
}
const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存')
// Dispatch scans retain the immutable DS source; the real OB is related through its dispatch and PDA picks.
const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水')
try {
if (!nonBlank(picker?.token)) {
blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪')
return
}
if (!stockBeforePick || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存')
const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId)
context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response,
success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS)
if (!sourceIds) return
const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds
context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances }
const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds)
const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水')
const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances)
context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS)
if (!sourcesBeforeValid || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return
const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds)
const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水')
const shippedRows = ledgerAfterPick?.filter(row => !ledgerBeforePick.some(before => before.id === row.id))
const expectedPackages = Number(transferred.data.packageCount)
context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null,
stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages
inventorySourcesPresent(stockAfterPick, sourcePositionBalances) && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages
&& balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(stockAfterPick) === expectedPackages
&& groupQuantityTotal(stockBeforePick) - groupQuantityTotal(stockAfterPick) === expectedPackages
&& expectedPackages > 0 && shippedRows?.length > 0
&& -shippedRows.reduce((sum, row) => sum + row.deltaPackages, 0) === expectedPackages
&& shippedRows.every(row => row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages),
`before=${quantityTotal(stockBeforePick)}, after=${quantityTotal(stockAfterPick)}, expected=${expectedPackages}, SHIPRows=${shippedRows?.length ?? 'missing'}`, OPTIONS)
const ready = await pda(context, picker.token, 'GET', '发车前回查服务端canDepart门禁',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
context.assert(MODULE, '发车前服务端canDepart明确为true', ready.response,
ready.data?.canDepart === true && ready.data?.status === 'READY_TO_DEPART', trace(ready.response), OPTIONS)
if (!success(ready.response) || ready.data?.canDepart !== true) return
const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo)
if (!ready) return
const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` }
const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库',
`${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody)
@@ -764,12 +851,13 @@ export async function run(context) {
departReplay.data?.replayed === true
&& Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages),
trace(departReplay.response), OPTIONS)
const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存')
const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds)
const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水')
context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response,
sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS)
inventorySourcesPresent(stockAfterDepart, sourcePositionBalances)
&& sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS)
context.createdResources.phase5OverseasFulfillment.inventoryProof = {
inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick),
sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick),
afterPackages: quantityTotal(stockAfterDepart), expectedPackages,
shipmentLedgerIds: (shippedRows || []).map(row => row.id),
canDepart: ready.data?.canDepart === true,