This commit is contained in:
2026-09-30 14:01:52 +08:00
parent 041a27c325
commit 39af04f169
@@ -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