From 39af04f169b184f6b01053218677867f881ce212 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 14:01:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../phase5-overseas-fulfillment.mjs | 111 ++++++++++++++---- 1 file changed, 91 insertions(+), 20 deletions(-) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index c5edd0bd8..5110e5a11 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -113,6 +113,13 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源') return null } + const inventoryEvidence = context.createdResources?.phase5OverseasFulfillment + if (inventoryEvidence) { + inventoryEvidence.inventoryReadEvidence ||= {} + inventoryEvidence.inventoryReadEvidence[name] = { customerId, warehouseId, inboundCodes: [...codes], + sourceBalanceIds: sourceIds, complete: false, globalGroups: null, sourceBalances: [] } + } + const readEvidence = inventoryEvidence?.inventoryReadEvidence?.[name] const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, { customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`) if (!groups) return null @@ -121,6 +128,7 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun && new Set(groups.map(balanceDimension)).size === groups.length context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS) if (!groupValid) return null + if (readEvidence) readEvidence.globalGroups = inventoryQuantitySnapshot(groups) context.context ||= {} context.context.phase5InventorySources ||= {} const frozen = context.context.phase5InventorySources @@ -167,6 +175,7 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun if (!included) return null frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] } } + if (readEvidence) readEvidence.sourceBalances.push({ balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions) }) 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=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS) @@ -183,6 +192,7 @@ export async function inventorySnapshot(context, customerId, warehouseId, inboun .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) + if (readEvidence) readEvidence.complete = jointlyIncluded return jointlyIncluded ? snapshot : null } @@ -218,10 +228,20 @@ async function shipmentLedger(context, customerId, outboundNo, name, businessTyp && (!businessOrderNo || row.businessOrderNo === businessOrderNo)) context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length, `rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS) - return exact.map(row => ({ id: id(row.id), inboundCode: row.inboundCode, - palletNo: row.palletNo, deltaPackages: Number(row.deltaPackages), - beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) + const quantities = rows.map(row => ({ id: id(row.id), customerId: id(row.customerId), + overseasWarehouseId: id(row.overseasWarehouseId), inboundOrderId: id(row.inboundOrderId), + inboundOrderNo: id(row.inboundOrderNo), inboundCode: id(row.inboundCode), palletNo: id(row.palletNo), + batchNo: id(row.batchNo), skuCode: id(row.skuCode), businessType: id(row.businessType), + businessOrderNo: id(row.businessOrderNo), outboundOrderNo: id(row.outboundOrderNo), + deltaPackages: Number(row.deltaPackages), beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) .sort((a, b) => a.id.localeCompare(b.id)) + const owned = context.createdResources?.phase5OverseasFulfillment + if (owned) { + owned.ledgerReadEvidence ||= {} + owned.ledgerReadEvidence[name] = { customerId, outboundNo, businessType, businessOrderNo: businessOrderNo || null, + complete: exact.length === rows.length, rows: quantities } + } + return exact.length === rows.length ? quantities : null } const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) @@ -229,6 +249,56 @@ const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, 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) +export function dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, + sourcePositionBalances, stockBefore, stockAfter, ledgerBefore, ledgerAfter }) { + const positions = snapshot => (snapshot || []).flatMap(row => row.positions || []) + const referenceKeys = new Set(sourcePositionBalances.map(row => `${row.balanceId}:${row.positionId}`)) + const selected = snapshot => positions(snapshot).filter(row => referenceKeys.has(`${row.balanceId}:${row.id}`)) + const positionBefore = selected(stockBefore) + const positionAfter = selected(stockAfter) + const newLedger = ledgerAfter?.filter(row => !ledgerBefore?.some(before => before.id === row.id)) || [] + const rowChecks = newLedger.map(row => ({ id: row.id, + identity: row.customerId === customerId && row.overseasWarehouseId === warehouseId + && row.businessType === 'SHIP' && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo + && positionBefore.some(position => position.palletNo === row.palletNo), + quantities: [row.beforePackages, row.deltaPackages, row.afterPackages].every(Number.isSafeInteger) + && row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages })) + const identity = row => Object.fromEntries(INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, row[field]])) + const sourcesStable = positionBefore.every(row => { + const after = positionAfter.find(value => value.id === row.id && value.balanceId === row.balanceId) + return after && JSON.stringify(identity(row)) === JSON.stringify(identity(after)) + }) + const unselectedBefore = positions(stockBefore).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) + const unselectedAfter = positions(stockAfter).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) + const totals = { positionBefore: quantityTotal(positionBefore), positionAfter: quantityTotal(positionAfter), + rawBefore: balanceQuantityTotal(stockBefore), rawAfter: balanceQuantityTotal(stockAfter), + groupBefore: groupQuantityTotal(stockBefore), groupAfter: groupQuantityTotal(stockAfter), + ledgerShipped: -newLedger.reduce((sum, row) => sum + row.deltaPackages, 0), expectedPackages } + const checks = { + fixedSourcesPresent: inventorySourcesPresent(stockBefore, sourcePositionBalances) + && inventorySourcesPresent(stockAfter, sourcePositionBalances), + sourceIdentitiesStable: sourcesStable, + unselectedPositionsUnchanged: sameSnapshot(inventoryQuantitySnapshot(unselectedBefore), inventoryQuantitySnapshot(unselectedAfter)), + positionConservation: expectedPackages > 0 && totals.positionBefore - totals.positionAfter === expectedPackages, + rawConservation: stockBefore !== null && stockAfter !== null && totals.rawBefore - totals.rawAfter === expectedPackages, + groupConservation: stockBefore !== null && stockAfter !== null && totals.groupBefore - totals.groupAfter === expectedPackages, + ledgerSourceIdentities: ledgerBefore !== null && ledgerAfter !== null && newLedger.length > 0 + && new Set(newLedger.map(row => row.id)).size === newLedger.length && rowChecks.every(row => row.identity), + ledgerQuantities: newLedger.length > 0 && totals.ledgerShipped === expectedPackages && rowChecks.every(row => row.quantities), + } + const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks, + snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } } + if (context.createdResources?.phase5OverseasFulfillment) { + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof) + } + for (const [name, passed] of Object.entries(checks)) { + context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS) + } + const passed = Object.values(checks).every(Boolean) + context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, passed, JSON.stringify(totals), OPTIONS) + return { passed, proof, newLedger } +} + // A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) @@ -820,22 +890,20 @@ export async function run(context) { 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 ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo) + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } }) const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) - if (!sourcesBeforeValid || !ledgerBeforePick || !await pickAll(context, picker.token, dispatchNo)) return + if (!sourcesBeforeValid || !ledgerBeforePick) return + const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo) 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 ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo) const expectedPackages = Number(transferred.data.packageCount) - context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, - 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 conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, + sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick, + ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick }) + const shippedRows = conservation.newLedger + if (!pickedSuccessfully) return const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) if (!ready) return const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } @@ -852,16 +920,19 @@ export async function run(context) { && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds) - const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水') + const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo) + const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof + inventoryProof.snapshots.afterDepart = stockAfterDepart + inventoryProof.ledgers.afterDepart = ledgerAfterDepart context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, inventorySourcesPresent(stockAfterDepart, sourcePositionBalances) && sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) - context.createdResources.phase5OverseasFulfillment.inventoryProof = { - sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: quantityTotal(stockBeforePick), - afterPackages: quantityTotal(stockAfterDepart), expectedPackages, + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { + sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore, + afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages, shipmentLedgerIds: (shippedRows || []).map(row => row.id), canDepart: ready.data?.canDepart === true, - } + }) } finally { await disablePicker(context, picker, 5) if (picker) picker.token = null