test(fulfillment): reconcile exact source positions and inventory balances

This commit is contained in:
2026-09-30 13:15:20 +08:00
parent eae630e60d
commit 74f0d823da
2 changed files with 140 additions and 16 deletions
@@ -88,24 +88,66 @@ async function allPages(context, path, values, name) {
return null
}
const POSITION_BUCKETS = ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
const BALANCE_BUCKETS = [...POSITION_BUCKETS, 'inTransitPackages', 'procurementPackages']
const INVENTORY_IDENTITIES = ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId',
'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', 'palletId', 'palletNo',
'batchNo', 'warehouseLocationId', 'warehouseLocationCode']
export function inventoryQuantitySnapshot(rows) {
return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode,
totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages),
holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages),
pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id))
return (rows || []).map(row => Object.fromEntries([
...INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, id(row[field])]),
...BALANCE_BUCKETS.filter(field => row[field] !== undefined).map(field => [field, Number(row[field])]),
])).sort((a, b) => a.id.localeCompare(b.id))
}
async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) {
const rows = []
for (const inboundCode of [...new Set(inboundCodes)]) {
export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) {
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
// The server's text filter may match child codes; inventory evidence must match the exact ticket.
rows.push(...current.filter(row => row.inboundCode === inboundCode
&& id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId))
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)
}
}
return inventoryQuantitySnapshot(rows)
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 }))
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)
&& 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))
context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid,
`balanceId=${id(balance.id)}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS)
if (!valid) return null
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)
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,
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() })
}
return snapshot.sort((a, b) => a.id.localeCompare(b.id))
}
async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) {
@@ -124,6 +166,7 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp
}
const quantityTotal = rows => (rows || []).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)
// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof.
@@ -258,6 +301,7 @@ export async function completeReturnedException(context, { customerId, warehouse
const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id))
context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null,
stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1
&& balanceQuantityTotal(stockAfter) - balanceQuantityTotal(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)
@@ -697,6 +741,7 @@ export async function run(context) {
const expectedPackages = Number(transferred.data.packageCount)
context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null,
stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages
&& balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(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),