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
@@ -1,5 +1,5 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, verifyPartialOrderCompletion,
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, verifyDepartureReadiness, verifyPartialOrderCompletion,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
@@ -173,13 +173,13 @@ test('same dispatch-return exception reaches actual restored inventory and retri
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 }] : [])
: [{ id: 'OLD', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A',
: [{ id: '61', 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',
if (path.includes('/inventory/balances')) { const balance = { id: '1', customerId: '100',
overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8,
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] })
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }; return ok(path.includes('/balances/1?') ? balance : { total: 1, records: [balance] }) }
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 }] : [] })
@@ -198,7 +198,7 @@ test('same dispatch-return exception reaches actual restored inventory and retri
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] })
})
context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
const resume = initialStatus === 'PROCESSING'
? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null
const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume })
@@ -218,7 +218,7 @@ test('same dispatch-return exception reaches actual restored inventory and retri
test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => {
const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 },
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }))
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
assert.equal(await completeReturnedException(context,
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null)
assert.equal(context.calls.length, 1)
@@ -227,18 +227,24 @@ test('PROCESSING continuation without the original pallet and evidence remains b
test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => {
const context = contextFor((method, path) => {
if (path.includes('/inventory/')) return ok({ records: [], total: 0 })
if (path.includes('/inventory/ledger')) return ok({ records: [], total: 0 })
if (path.includes('/inventory/balances')) {
const balance = { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', totalPackages: 1, availablePackages: 1,
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }
return ok(path.includes('/positions') ? [{ ...balance, id: '61', balanceId: '1', inboundCode: 'IN-A' }]
: path.includes('/balances/1?') ? balance : { records: [balance], total: 1 })
}
if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 })
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } }
return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 },
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })
})
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1' }
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' }
assert.equal(await completeReturnedException(context,
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null)
assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 2)
assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 6)
assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2)
assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed))
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
@@ -321,13 +327,13 @@ function inventoryFixture() {
}
function inventoryContext(fixture) {
return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions)
: ok({ records: [fixture.balance], total: 1 }))
: path.includes('/balances/1?') ? ok(fixture.balance) : 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')
const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock', ['1'])
assert.equal(snapshot.length, 1)
assert.equal(snapshot[0].id, '1')
assert.equal(snapshot[0].totalPackages, 7)
@@ -342,7 +348,9 @@ test('inventory evidence resolves missing Balance inboundCode through exact posi
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'), [])
const childSnapshot = await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock', ['1'])
assert.equal(childSnapshot[0].totalPackages, 0)
assert.deepEqual(childSnapshot[0].matchingPositionIds, [])
})
test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => {
@@ -361,24 +369,110 @@ test('inventory evidence rejects foreign balances and positions, mismatched bala
]) {
const fixture = inventoryFixture(); mutate(fixture)
const context = inventoryContext(fixture)
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null)
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock', ['1']), 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')
const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock', ['1'])
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)
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), 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)
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), 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)
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original)
}
})
test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => {
for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [
['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true],
['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false],
['PICKING', true, 'READY_TO_DEPART', 'DS-1', false],
['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false],
['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false],
['COMPLETED', true, 'DEPARTED', 'DS-1', false],
['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false],
]) {
const context = contextFor((method, path) => path.startsWith('/api/pda/')
? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus }))
assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected)
assert.equal(context.calls.length, 2)
assert.equal(context.calls.some(call => call.method !== 'GET'), false)
assert.equal(context.assertions.some(row => !row.passed), !expected)
}
})
test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => {
const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [
{ positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] }
assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'],
sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] })
assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null)
assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null)
assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null)
let shipped = false
const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1',
destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6,
availablePackages: balanceId === '1' ? 3 : 6, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0,
receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 })
const context = contextFor((method, path) => {
assert.equal(new URL(`https://test.invalid${path}`).searchParams.has('inboundCode'), false)
const match = path.match(/balances\/(1|2)(\/positions)?\?/)
if (!match) return ok({ records: [{ ...source(shipped ? '99' : '1'), totalPackages: shipped ? 3 : 12, availablePackages: shipped ? 3 : 12 }], total: 1 })
const balance = source(match[1])
if (match[2]) return ok([{ ...balance, id: match[1] === '1' ? '11' : '12', balanceId: match[1], inboundCode: 'IN-A',
totalPackages: shipped ? 0 : balance.totalPackages, availablePackages: shipped ? 0 : balance.availablePackages }])
return shipped ? { http: 404, body: { code: 'A0404' } } : ok(balance)
})
const frozenSources = dispatchInventorySources(task, 'DS-1', 'WH-1')
const frozenIds = frozenSources.sourceBalanceIds
const before = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', frozenIds)
assert.equal(inventorySourcesPresent(before, frozenSources.sourcePositionBalances), true)
assert.equal(inventorySourcesPresent(before, [{ positionId: '11', balanceId: '2' }]), false)
assert.equal(inventorySourcesPresent(before, [{ positionId: 'UNKNOWN', balanceId: '1' }]), false)
assert.equal(before.reduce((sum, row) => sum + row.totalPackages, 0), 9)
assert.equal(before.reduce((sum, row) => sum + row.balance.totalPackages, 0), 9)
assert.equal(before[0].globalGroups[0].totalPackages, 12)
shipped = true
const after = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', frozenIds)
assert.equal(inventorySourcesPresent(after, frozenSources.sourcePositionBalances), true)
assert.equal(after.reduce((sum, row) => sum + row.totalPackages, 0), 0)
assert.equal(after.reduce((sum, row) => sum + row.balance.totalPackages, 0), 0)
assert.equal(after[0].globalGroups[0].totalPackages, 3)
assert.deepEqual(after, await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'replay', frozenIds))
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
})
test('source snapshot never treats unknown or incompletely proven raw 404 as zero inventory', async () => {
const missing = { http: 404, body: { code: 'A0404' } }
for (const mode of ['unknown', 'positions-failure', 'position-nonzero', 'group-not-decreased']) {
let absent = false
const fixture = inventoryFixture()
fixture.balance.inTransitPackages = 0; fixture.balance.procurementPackages = 0
const context = contextFor((method, path) => {
if (path.includes('/positions')) {
if (absent && mode === 'positions-failure') return missing
return ok(fixture.positions.map(row => absent && mode !== 'position-nonzero'
? { ...row, totalPackages: 0, availablePackages: 0 } : row))
}
if (path.includes('/balances/1?')) return absent ? missing : ok(fixture.balance)
return ok({ total: absent && mode !== 'group-not-decreased' ? 0 : 1,
records: absent && mode !== 'group-not-decreased' ? [] : [fixture.balance] })
})
if (mode !== 'unknown') assert.ok(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', ['1']))
absent = true
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', ['1']), null)
assert.ok(context.assertions.some(row => !row.passed))
}
const context = contextFor(() => { throw new Error('Missing fixed IDs cannot issue HTTP reads') })
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null)
})