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
@@ -1,5 +1,5 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, verifyPartialOrderCompletion,
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, verifyPartialOrderCompletion,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
@@ -169,12 +169,17 @@ test('same dispatch-return exception reaches actual restored inventory and retri
let uploads = 0
const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }
const context = contextFor((method, path, body) => {
if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1',
if (path.includes('/inventory/balances/1/positions')) return ok(path.includes('palletNo=') ? (completed ? [{ id: '3', balanceId: '1',
customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71',
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1 }])
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1,
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])
: [{ id: 'OLD', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A',
palletNo: 'SOURCE', totalPackages: 8, availablePackages: 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 },
...(completed ? [{ id: '3', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71',
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])])
if (path.includes('/inventory/balances')) return ok({ total: 1, records: [{ id: '1', customerId: '100',
overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8,
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] })
overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8,
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] })
if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2',
customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1',
outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] })
@@ -303,3 +308,77 @@ test('incomplete SIGNED-B producer remains a blocker and never logs in as the pr
assert.ok(context.assertions.some(row => !row.passed))
assert.equal(context.context.phase5TailVerification, undefined)
})
function inventoryFixture() {
const position = (positionId, inboundCode, packages) => ({ id: positionId, balanceId: '1', customerId: '100',
overseasWarehouseId: 'WH-1', inboundCode, inboundOrderId: `ORDER-${inboundCode}`, palletId: `PALLET-${positionId}`,
palletNo: `P-${positionId}`, batchNo: 'B-1', warehouseLocationId: 'LOCATION-1', warehouseLocationCode: 'L-1',
totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 })
return { balance: { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED',
totalPackages: 9, availablePackages: 9, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0,
receivingPackages: 0, inTransitPackages: 4, procurementPackages: 2 },
positions: [position('11', 'IN-A', 3), position('12', 'IN-A-1', 2), position('13', 'IN-B', 4)] }
}
function inventoryContext(fixture) {
return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions)
: ok({ records: [fixture.balance], total: 1 }))
}
test('inventory evidence resolves missing Balance inboundCode through exact positions and retains an independent shared balance', async () => {
const fixture = inventoryFixture()
const context = inventoryContext(fixture)
const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock')
assert.equal(snapshot.length, 1)
assert.equal(snapshot[0].id, '1')
assert.equal(snapshot[0].totalPackages, 7)
assert.deepEqual(snapshot[0].inboundCodes, ['IN-A', 'IN-B'])
assert.deepEqual(snapshot[0].matchingPositionIds, ['11', '13'])
assert.equal(snapshot[0].balance.totalPackages, 9)
assert.equal(snapshot[0].balance.inTransitPackages, 4)
assert.equal(snapshot[0].balance.procurementPackages, 2)
assert.equal(snapshot[0].positions.length, 3)
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
assert.equal(context.calls.filter(call => call.path.includes('/positions')).length, 1)
const childOnly = inventoryFixture()
childOnly.positions = [childOnly.positions[1]]
childOnly.balance.totalPackages = 2; childOnly.balance.availablePackages = 2
assert.deepEqual(await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock'), [])
})
test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => {
for (const mutate of [
fixture => { fixture.balance.customerId = 'OTHER' },
fixture => { fixture.balance.overseasWarehouseId = 'OTHER' },
fixture => { fixture.positions[0].customerId = 'OTHER' },
fixture => { fixture.positions[0].overseasWarehouseId = 'OTHER' },
fixture => { fixture.positions[0].balanceId = 'OTHER' },
fixture => { fixture.positions[0].id = fixture.positions[1].id },
fixture => { fixture.positions[0].inboundCode = '' },
fixture => { fixture.positions[0].receivingPackages = 1 },
fixture => { fixture.balance.inTransitPackages = null },
fixture => { delete fixture.positions[0].receivingPackages },
fixture => { fixture.balance.availablePackages = 8 },
]) {
const fixture = inventoryFixture(); mutate(fixture)
const context = inventoryContext(fixture)
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null)
assert.ok(context.assertions.some(row => !row.passed))
}
})
test('inventory evidence captures zero drift independently for every balance bucket, position identity, and unselected sibling position', async () => {
const fixture = inventoryFixture()
const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock')
for (const field of ['inTransitPackages', 'procurementPackages']) {
const changed = inventoryFixture(); changed.balance[field] += 1
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original)
}
for (const field of ['palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'inboundOrderId']) {
const changed = inventoryFixture(); changed.positions[2][field] = 'DIFFERENT'
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original)
}
for (const field of ['holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']) {
const changed = inventoryFixture(); changed.positions[2][field] += 1; changed.balance[field] += 1
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock'), original)
}
})
@@ -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),