This commit is contained in:
2026-09-30 17:13:11 +08:00
parent babc7de4b6
commit 32fb9ec7b3
26 changed files with 1484 additions and 70 deletions
@@ -61,6 +61,165 @@ function receivingSnapshot(data) {
})
}
const RECEIVING_BALANCE_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
const RECEIVING_POSITION_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
const RECEIVING_FIELDS = {
balances: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS],
positions: ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseCode', 'palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'status', ...RECEIVING_POSITION_BUCKETS],
stockReceipts: ['id', 'customerId', 'planId', 'taskId', 'allocationId', 'lineId', 'orderId', 'inboundCode', 'skuCode', 'warehousePalletId', 'palletNo', 'warehouseLocationId', 'warehouseLocationCode', 'batchNo', 'receivedPackages', 'inventoryBalanceId', 'inventoryPositionId', 'inventoryOperationKey'],
inventoryOperations: ['id', 'customerId', 'idempotencyKey', 'operationType', 'requestHash', 'status'],
stockInLedger: ['id', 'customerId', 'balanceId', 'positionId', 'overseasWarehouseId', 'inboundOrderId', 'inboundCode', 'palletId', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'sourceEventId', 'idempotencyKey', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'],
receivingOperations: ['id', 'sessionId', 'action', 'idempotencyKey', 'requestHash'],
httpGroups: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS],
httpLedger: ['id', 'customerId', 'overseasWarehouseId', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'],
}
RECEIVING_FIELDS.palletComposition = RECEIVING_FIELDS.positions
const receivingNumberFields = new Set([...RECEIVING_BALANCE_BUCKETS, 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages'])
const receivingArrays = Object.keys(RECEIVING_FIELDS)
const sameEvidence = (left, right) => JSON.stringify(left) === JSON.stringify(right)
const receivingTotal = (rows, field) => rows.reduce((total, row) => total + row[field], 0)
const balanceGroupKey = row => JSON.stringify([row.customerId, row.overseasWarehouseId, row.destinationWarehouseId, row.destinationWarehouseCode, row.skuCode])
// Only safe business identities and bucket facts enter reports. Never save reader connection,
// tokens, operator identifiers, request/result JSON, binary files or arbitrary provider fields.
export function normalizeReceivingEvidence(value) {
if (!value || receivingArrays.some(name => !Array.isArray(value[name]))) return null
return Object.fromEntries(receivingArrays.map(name => [name, value[name].map(row =>
Object.fromEntries(RECEIVING_FIELDS[name].map(field => [field, receivingNumberFields.has(field)
? (row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) ? Number(row[field]) : null)
: row[field] == null ? null : id(row[field])]))).sort((a, b) => id(a.id).localeCompare(id(b.id)))]))
}
export function receivingEvidenceValid(snapshot, scope) {
if (!snapshot || receivingArrays.some(name => !Array.isArray(snapshot[name]) || snapshot[name].some(row => !longId(row.id))
|| new Set(snapshot[name].map(row => row.id)).size !== snapshot[name].length)) return false
const bucketsValid = (row, fields) => fields.every(field => Number.isSafeInteger(row[field]) && row[field] >= 0)
const owned = row => row.customerId === scope.customerId && row.overseasWarehouseId === scope.warehouseId
if (!snapshot.balances.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS))
|| !snapshot.httpGroups.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS))
|| !snapshot.httpLedger.every(owned)
|| !snapshot.positions.every(row => owned(row) && bucketsValid(row, RECEIVING_POSITION_BUCKETS)
&& snapshot.balances.some(balance => balance.id === row.balanceId) && longId(row.palletId) && nonBlank(row.palletNo)
&& longId(row.warehouseLocationId) && nonBlank(row.warehouseLocationCode))
|| !sameEvidence(snapshot.positions, snapshot.palletComposition)) return false
if (!snapshot.balances.every(balance => RECEIVING_POSITION_BUCKETS.every(field =>
receivingTotal(snapshot.positions.filter(row => row.balanceId === balance.id), field) === balance[field]))) return false
const groups = new Map()
for (const balance of snapshot.balances) {
const key = balanceGroupKey(balance)
const current = groups.get(key) || Object.fromEntries(RECEIVING_BALANCE_BUCKETS.map(field => [field, 0]))
for (const field of RECEIVING_BALANCE_BUCKETS) current[field] += balance[field]
groups.set(key, current)
}
for (const [key, totals] of groups) if (!RECEIVING_BALANCE_BUCKETS.some(field => totals[field] > 0)) groups.delete(key)
if (groups.size !== snapshot.httpGroups.length || new Set(snapshot.httpGroups.map(balanceGroupKey)).size !== snapshot.httpGroups.length
|| !snapshot.httpGroups.every(row => groups.has(balanceGroupKey(row)) && RECEIVING_BALANCE_BUCKETS.every(field => row[field] === groups.get(balanceGroupKey(row))[field]))) return false
if (!snapshot.stockReceipts.every(row => row.customerId === scope.customerId && scope.inboundCodes.includes(row.inboundCode)
&& longId(row.planId) && longId(row.taskId) && longId(row.allocationId) && longId(row.lineId) && longId(row.orderId)
&& nonBlank(row.skuCode) && Number.isSafeInteger(row.receivedPackages) && row.receivedPackages > 0)) return false
if (snapshot.inventoryOperations.length !== snapshot.stockReceipts.length || snapshot.stockInLedger.length !== snapshot.stockReceipts.length) return false
for (const receipt of snapshot.stockReceipts) {
const baseKey = `PDA_STOCK_IN:${receipt.taskId}:${receipt.allocationId}:${receipt.skuCode}`
const operations = snapshot.inventoryOperations.filter(row => row.idempotencyKey === baseKey)
if (receipt.inventoryOperationKey !== baseKey || operations.length !== 1) return false
const operation = operations[0]
const ledgers = snapshot.stockInLedger.filter(row => row.idempotencyKey === `${baseKey}:${operation.id}`)
if (operation.customerId !== scope.customerId || operation.operationType !== 'STOCK_IN' || operation.status !== 'COMPLETED'
|| !/^[a-f0-9]{64}$/.test(operation.requestHash || '') || ledgers.length !== 1) return false
const ledger = ledgers[0]
const balance = snapshot.balances.find(row => row.id === receipt.inventoryBalanceId)
const position = snapshot.positions.find(row => row.id === receipt.inventoryPositionId)
if (!balance || !position || position.balanceId !== balance.id || !owned(ledger) || ledger.businessType !== 'STOCK_IN'
|| ledger.balanceId !== balance.id || ledger.positionId !== position.id || ledger.inboundOrderId !== receipt.orderId
|| ledger.inboundCode !== receipt.inboundCode || ledger.skuCode !== receipt.skuCode || !nonBlank(ledger.sourceEventId)
|| ledger.palletId !== receipt.warehousePalletId || ledger.palletNo !== receipt.palletNo || ledger.batchNo !== receipt.batchNo
|| position.palletId !== receipt.warehousePalletId || position.palletNo !== receipt.palletNo
|| position.warehouseLocationId !== receipt.warehouseLocationId || position.warehouseLocationCode !== receipt.warehouseLocationCode
|| ledger.deltaPackages !== receipt.receivedPackages || ledger.afterPackages - ledger.beforePackages !== receipt.receivedPackages
|| ledger.statusTo !== 'AVAILABLE') return false
}
return snapshot.receivingOperations.every(row => row.action === 'COMPLETE' && longId(row.sessionId)
&& nonBlank(row.idempotencyKey) && /^[a-f0-9]{64}$/.test(row.requestHash || ''))
}
export function receivingCompletionProof(scope, stages) {
const names = ['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION']
const snapshotsValid = names.every(name => receivingEvidenceValid(stages[name], scope))
const checks = { completeScope: scope.inboundCodes.length > 0 && new Set(scope.inboundCodes).size === scope.inboundCodes.length
&& scope.inboundCodes.every(code => Number.isSafeInteger(scope.expectedPackagesByInbound[code]) && scope.expectedPackagesByInbound[code] > 0)
&& scope.sourceInboundIds.length === scope.inboundCodes.length && new Set(scope.sourceInboundIds).size === scope.sourceInboundIds.length,
fourCompleteSnapshots: snapshotsValid }
if (!snapshotsValid) return { passed: false, checks }
const before = stages.BEFORE_COMPLETE, after = stages.AFTER_COMPLETE
const expected = Object.values(scope.expectedPackagesByInbound).reduce((sum, count) => sum + count, 0)
checks.freshSourceHasNoPriorStock = ['stockReceipts', 'inventoryOperations', 'stockInLedger', 'receivingOperations'].every(name => before[name].length === 0)
checks.exactSourceReceiptAndLedgerQuantities = scope.inboundCodes.every(code => receivingTotal(after.stockReceipts.filter(row => row.inboundCode === code), 'receivedPackages') === scope.expectedPackagesByInbound[code]
&& receivingTotal(after.stockInLedger.filter(row => row.inboundCode === code), 'deltaPackages') === scope.expectedPackagesByInbound[code])
checks.completeCommandExactlyOnce = after.receivingOperations.length === 1 && after.receivingOperations[0].idempotencyKey === scope.completeKey
checks.firstCompleteAllBalanceBuckets = RECEIVING_BALANCE_BUCKETS.every(field => receivingTotal(after.balances, field) - receivingTotal(before.balances, field)
=== (field === 'availablePackages' ? expected : field === 'inTransitPackages' ? -scope.expectedTransitPackages : 0))
checks.firstCompleteAllPositionBuckets = RECEIVING_POSITION_BUCKETS.every(field => receivingTotal(after.positions, field) - receivingTotal(before.positions, field)
=== (field === 'availablePackages' ? expected : 0))
const touchedBalances = new Set(after.stockReceipts.map(row => row.inventoryBalanceId))
const touchedPositions = new Set(after.stockReceipts.map(row => row.inventoryPositionId))
checks.untouchedBalancesAndPositionsUnchanged = sameEvidence(before.balances.filter(row => !touchedBalances.has(row.id)), after.balances.filter(row => !touchedBalances.has(row.id)))
&& sameEvidence(before.positions.filter(row => !touchedPositions.has(row.id)), after.positions.filter(row => !touchedPositions.has(row.id)))
const oldLedgerIds = new Set(before.httpLedger.map(row => row.id))
const delta = after.httpLedger.filter(row => !oldLedgerIds.has(row.id))
checks.publicImmutableLedgerPreservedAndNewStockExact = sameEvidence(before.httpLedger, after.httpLedger.filter(row => oldLedgerIds.has(row.id)))
&& delta.filter(row => row.businessType === 'STOCK_IN').length === after.stockInLedger.length
&& delta.filter(row => row.businessType === 'STOCK_IN').every(row => after.stockInLedger.some(source => source.id === row.id && source.deltaPackages === row.deltaPackages))
&& delta.every(row => scope.inboundCodes.includes(row.inboundCode) && ['STOCK_IN', 'STOCK_IN_TRANSIT_RELEASE'].includes(row.businessType))
checks.sameKeyReplayAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_SAME_KEY_REPLAY)
checks.freshKeyRejectedAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_FRESH_KEY_REJECTION)
return { passed: Object.values(checks).every(Boolean), checks, expectedPackages: expected, expectedTransitPackages: scope.expectedTransitPackages }
}
async function receivingEvidencePages(context, scope, resource, name) {
const collected = []
for (let page = 1; page <= 100; page++) {
const response = await context.request('GET', `/api/platform/v1/last-leg/inventory/${resource}?customerId=${encodeURIComponent(scope.customerId)}&overseasWarehouseId=${encodeURIComponent(scope.warehouseId)}&page=${page}&size=200`)
const data = response.body?.data
const valid = success(response) && Array.isArray(data?.records) && Number.isSafeInteger(Number(data?.total)) && Number(data.total) >= 0
context.addStep(MODULE, `${name}完整客户仓${resource}第${page}页`, response, valid, trace(response), OPTIONS)
if (!valid) return null
collected.push(...data.records)
if (collected.length >= Number(data.total)) {
const complete = collected.length === Number(data.total) && new Set(collected.map(row => id(row.id))).size === collected.length
context.assert(MODULE, `${name}${resource}分页无截断重复`, response, complete, `rows=${collected.length}, total=${data.total}`, OPTIONS)
return complete ? collected : null
}
if (!data.records.length) break
}
blockerGap(context, `${name}库存分页`, '完整客户仓分页未读完,不能冻结截断库存', 'receiving-replay-evidence')
return null
}
export async function captureReceivingEvidence(context, scope, stage, target) {
const slot = target.stages[stage] = { captured: false, evidence: null }
const reader = context.context?.phase4ReceivingEvidenceReader
if (typeof reader !== 'function') {
blockerGap(context, `${stage}原始库存与不可变完整键取证`, '未注入phase4ReceivingEvidenceReader;公共ledger不返回原始balance/position/幂等键且无immutable receipt接口,不能以终态补历史快照', 'receiving-replay-evidence')
return null
}
try {
const httpGroups = await receivingEvidencePages(context, scope, 'balances', stage)
const httpLedger = await receivingEvidencePages(context, scope, 'ledger', stage)
if (!httpGroups || !httpLedger) return null
const raw = await reader({ ...scope, stage })
const evidence = normalizeReceivingEvidence({ ...raw, httpGroups, httpLedger })
slot.evidence = evidence
slot.captured = receivingEvidenceValid(evidence, scope)
context.assert(MODULE, `${stage}完整raw库存位置托盘组成及收货完整键核平`, null, slot.captured,
`bookingId=${scope.bookingId}, balances=${evidence?.balances.length ?? 'missing'}, positions=${evidence?.positions.length ?? 'missing'}, receipts=${evidence?.stockReceipts.length ?? 'missing'}`, OPTIONS)
return evidence
} catch {
slot.error = 'Read-only receiving evidence reader failed; sensitive connection/provider details omitted'
blockerGap(context, `${stage}只读取证`, slot.error, 'receiving-replay-evidence')
return null
}
}
export function expandOverseasCartonCandidates(inbounds, producerCartonMarks = []) {
const marks = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank)
@@ -533,7 +692,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa
return cartonFixtures
}
async function probeCartonResolveNegatives(context, token, current) {
export async function probeCartonResolveNegatives(context, token, current, runCrossContainerProbe = true) {
const fba = current.inbounds.find((item) => item.destinationType === 'FBA')
await expected(context, token, 'POST', '无#箱码被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
@@ -545,6 +704,7 @@ async function probeCartonResolveNegatives(context, token, current) {
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
if (runCrossContainerProbe) {
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
const crossContainerProbe = crossContainerCartonProbe(allProducers,current)
if (crossContainerProbe) {
@@ -556,6 +716,7 @@ async function probeCartonResolveNegatives(context, token, current) {
} else {
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
}
}
const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS')
if (overseas) {
@@ -747,7 +908,9 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(current.response)) return
const beforeResolve = receivingSnapshot(current.data)
await probeCartonResolveNegatives(context, token, current.data)
// Single-container completion/cancellation continuation does not provision a second
// loaded source. The default full receiving chain still requires its cross-container proof.
await probeCartonResolveNegatives(context, token, current.data, runAuthorizationProbes)
const producerCartonMarks = preferredProducer?.cartonMarks?.length
? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]
const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer)
@@ -841,11 +1004,35 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
return
}
const completeBody = { idempotencyKey: key(context, 'complete') }
const sourceInboundIds = [...new Set([id(preferredProducer?.sourceInboundId),
...(preferredProducer?.palletLabelManifest?.groups || []).flatMap(group => (group.labels || []).map(label => id(label.inboundId)))].filter(longId))].sort()
const scope = { customerId: id(preferredProducer?.tenantId), warehouseId: id(refreshed.data?.overseasWarehouseId),
bookingId: sourceContainerBookingId, containerNo,
inboundCodes: refreshed.data.inbounds.map(inbound => inbound.inboundCode).sort(), sourceInboundIds,
expectedPackagesByInbound: Object.fromEntries(refreshed.data.inbounds.map(inbound => [inbound.inboundCode, Number(inbound.totalPackages)])),
expectedTransitPackages: refreshed.data.inbounds.filter(inbound => inbound.orderType === 'FIRST_LEG').reduce((sum, inbound) => sum + Number(inbound.totalPackages), 0),
completeKey: completeBody.idempotencyKey, freshKey: key(context, 'complete-fresh-repeat') }
const receivingProof = { scope, stages: {}, proof: null, status: 'UNPROVEN' }
context.createdResources.phase4OverseasPda.receivingCompletionEvidence ||= {}
context.createdResources.phase4OverseasPda.receivingCompletionEvidence[sourceContainerBookingId] = receivingProof
await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', receivingProof)
const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', receivingProof)
if (!success(completed.response)) return
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS)
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED' && sameEvidence(completeReplay.data, completed.data), trace(completeReplay.response), OPTIONS)
await captureReceivingEvidence(context, scope, 'AFTER_SAME_KEY_REPLAY', receivingProof)
const freshComplete = await expected(context, token, 'POST', '已完成柜新键重复完成被拒绝且不能再次写库存',
'/api/pda/v1/last-leg/receiving-containers/current/complete', { idempotencyKey: scope.freshKey },
response => a0404Message(response, /^Current receiving container session was not found$/))
await captureReceivingEvidence(context, scope, 'AFTER_FRESH_KEY_REJECTION', receivingProof)
receivingProof.proof = receivingCompletionProof(scope, Object.fromEntries(Object.entries(receivingProof.stages).map(([stage, row]) => [stage, row.evidence])))
receivingProof.commandResults = { firstCompleteSucceeded: success(completed.response), sameKeyReturnedExactOriginal: success(completeReplay.response) && sameEvidence(completeReplay.data, completed.data),
freshKeyRejected: a0404Message(freshComplete, /^Current receiving container session was not found$/) }
receivingProof.status = receivingProof.proof.passed && Object.values(receivingProof.commandResults).every(Boolean) ? 'VERIFIED' : 'UNPROVEN'
for (const [check, passed] of Object.entries(receivingProof.proof.checks)) context.assert(MODULE, `收货完成库存守恒 ${check}`, null, passed,
`bookingId=${sourceContainerBookingId}, proofStatus=${receivingProof.status}`, OPTIONS)
const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null