diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 0313f5969..6c61baf67 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 5fc62a177..c5edd0bd8 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -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,