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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user