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 14c32d701..0313f5969 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, verifyPartialOrderCompletion, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, verifyPartialOrderCompletion, completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -169,12 +169,17 @@ test('same dispatch-return exception reaches actual restored inventory and retri let uploads = 0 const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 } const context = contextFor((method, path, body) => { - if (path.includes('/inventory/balances/1/positions')) return ok([{ id: '3', balanceId: '1', + if (path.includes('/inventory/balances/1/positions')) return ok(path.includes('palletNo=') ? (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 }]) + 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', + 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', - overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', totalPackages: completed ? 9 : 8, - availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0 }] }) + overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8, + availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] }) 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 }] : [] }) @@ -303,3 +308,77 @@ test('incomplete SIGNED-B producer remains a blocker and never logs in as the pr assert.ok(context.assertions.some(row => !row.passed)) assert.equal(context.context.phase5TailVerification, undefined) }) + +function inventoryFixture() { + const position = (positionId, inboundCode, packages) => ({ id: positionId, balanceId: '1', customerId: '100', + overseasWarehouseId: 'WH-1', inboundCode, inboundOrderId: `ORDER-${inboundCode}`, palletId: `PALLET-${positionId}`, + palletNo: `P-${positionId}`, batchNo: 'B-1', warehouseLocationId: 'LOCATION-1', warehouseLocationCode: 'L-1', + totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }) + return { balance: { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED', + totalPackages: 9, availablePackages: 9, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 4, procurementPackages: 2 }, + positions: [position('11', 'IN-A', 3), position('12', 'IN-A-1', 2), position('13', 'IN-B', 4)] } +} +function inventoryContext(fixture) { + return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions) + : 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') + assert.equal(snapshot.length, 1) + assert.equal(snapshot[0].id, '1') + assert.equal(snapshot[0].totalPackages, 7) + assert.deepEqual(snapshot[0].inboundCodes, ['IN-A', 'IN-B']) + assert.deepEqual(snapshot[0].matchingPositionIds, ['11', '13']) + assert.equal(snapshot[0].balance.totalPackages, 9) + assert.equal(snapshot[0].balance.inTransitPackages, 4) + assert.equal(snapshot[0].balance.procurementPackages, 2) + assert.equal(snapshot[0].positions.length, 3) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.filter(call => call.path.includes('/positions')).length, 1) + 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'), []) +}) + +test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => { + for (const mutate of [ + fixture => { fixture.balance.customerId = 'OTHER' }, + fixture => { fixture.balance.overseasWarehouseId = 'OTHER' }, + fixture => { fixture.positions[0].customerId = 'OTHER' }, + fixture => { fixture.positions[0].overseasWarehouseId = 'OTHER' }, + fixture => { fixture.positions[0].balanceId = 'OTHER' }, + fixture => { fixture.positions[0].id = fixture.positions[1].id }, + fixture => { fixture.positions[0].inboundCode = '' }, + fixture => { fixture.positions[0].receivingPackages = 1 }, + fixture => { fixture.balance.inTransitPackages = null }, + fixture => { delete fixture.positions[0].receivingPackages }, + fixture => { fixture.balance.availablePackages = 8 }, + ]) { + const fixture = inventoryFixture(); mutate(fixture) + const context = inventoryContext(fixture) + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), 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') + 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) + } + 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) + } + 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) + } +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 0f31e2a9f..5fc62a177 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -88,24 +88,66 @@ async function allPages(context, path, values, name) { return null } +const POSITION_BUCKETS = ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const BALANCE_BUCKETS = [...POSITION_BUCKETS, 'inTransitPackages', 'procurementPackages'] +const INVENTORY_IDENTITIES = ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', + 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', 'palletId', 'palletNo', + 'batchNo', 'warehouseLocationId', 'warehouseLocationCode'] + export function inventoryQuantitySnapshot(rows) { - return (rows || []).map(row => ({ id: id(row.id), inboundCode: row.inboundCode, - totalPackages: Number(row.totalPackages), availablePackages: Number(row.availablePackages), - holdPackages: Number(row.holdPackages), allocatedPackages: Number(row.allocatedPackages), - pickedPackages: Number(row.pickedPackages) })).sort((a, b) => a.id.localeCompare(b.id)) + return (rows || []).map(row => Object.fromEntries([ + ...INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, id(row[field])]), + ...BALANCE_BUCKETS.filter(field => row[field] !== undefined).map(field => [field, Number(row[field])]), + ])).sort((a, b) => a.id.localeCompare(b.id)) } -async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { - const rows = [] - for (const inboundCode of [...new Set(inboundCodes)]) { +export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name) { + 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 - // The server's text filter may match child codes; inventory evidence must match the exact ticket. - rows.push(...current.filter(row => row.inboundCode === inboundCode - && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId)) + 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) + } } - return inventoryQuantitySnapshot(rows) + 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 })) + 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) + && 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)) + context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid, + `balanceId=${id(balance.id)}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) + if (!valid) return null + 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) + 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, + 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() }) + } + return snapshot.sort((a, b) => a.id.localeCompare(b.id)) } async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { @@ -124,6 +166,7 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp } const quantityTotal = rows => (rows || []).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) // A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. @@ -258,6 +301,7 @@ export async function completeReturnedException(context, { customerId, warehouse const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id)) context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 + && balanceQuantityTotal(stockAfter) - balanceQuantityTotal(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) @@ -697,6 +741,7 @@ export async function run(context) { const expectedPackages = Number(transferred.data.packageCount) context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, stockAfterPick !== null && quantityTotal(stockBeforePick) - quantityTotal(stockAfterPick) === expectedPackages + && balanceQuantityTotal(stockBeforePick) - balanceQuantityTotal(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),