test(fulfillment): reconcile frozen PDA sources and dispatch departure gate
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user