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)
})
@@ -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,