test: integrate reliable event recovery and receiving evidence

This commit is contained in:
2026-10-02 20:14:17 +08:00
parent d0df738bba
commit 4b531c7e22
17 changed files with 1527 additions and 37 deletions
@@ -6,7 +6,54 @@ import { fileURLToPath } from 'node:url'
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer,
crossContainerCartonProbe, normalizeReceivingEvidence, receivingEvidenceValid,
receivingCompletionProof, captureReceivingEvidence, probeCartonResolveNegatives } from './phase4-overseas-pda.mjs'
receivingCompletionProof, captureReceivingEvidence, receivingEventRecoveryInventoryUnchanged,
probeCartonResolveNegatives, awaitReceivingCandidate } from './phase4-overseas-pda.mjs'
function candidateContext(responses) {
const calls=[],steps=[]
return {calls,steps,createdResources:{},addStep:(_module,name,_response,passed)=>steps.push({name,passed}),
request:async(method,path,_body,token)=>{calls.push({method,path,token});return responses[Math.min(calls.length-1,responses.length-1)]}}
}
const candidateResponse = data => ({http:200,body:{code:'200',data},requestId:'offline-request',traceId:'offline-trace'})
const exactCandidate = {containerNo:'TSTU6403402',sourceContainerBookingId:'2105838069516079106',operable:true,totalPackages:1}
const exactProducer = {containerNo:exactCandidate.containerNo,containerBookingId:exactCandidate.sourceContainerBookingId}
test('original r2 async shipping gate race waits same booking and container until visible without writing',async()=>{
const context=candidateContext([candidateResponse([]),candidateResponse([]),candidateResponse([exactCandidate])]),delays=[]
const result=await awaitReceivingCandidate(context,'offline-picker',exactProducer,{attempts:3,delay:async ms=>delays.push(ms)})
assert.deepEqual(result.candidate,exactCandidate)
assert.deepEqual(delays,[1000,1000])
assert.equal(context.calls.length,3)
assert.ok(context.calls.every(call=>call.method==='GET'&&call.token==='offline-picker'&&call.path.endsWith('/candidates')))
const proof=context.createdResources.phase4OverseasPda.candidateWaitEvidence[0]
assert.equal(proof.outcome,'READY');assert.deepEqual(proof.attempts.map(v=>v.exactMatches),[0,0,1])
assert.ok(proof.attempts.every(v=>v.requestId&&v.traceId))
})
test('same container from another booking, other container and nonoperable original cannot satisfy wait',async()=>{
for(const candidate of [{...exactCandidate,sourceContainerBookingId:'999'},
{...exactCandidate,containerNo:'ANOTHER'},{...exactCandidate,operable:false}]) {
const context=candidateContext([candidateResponse([candidate])]);let waits=0
const result=await awaitReceivingCandidate(context,'offline',exactProducer,{attempts:2,delay:async()=>waits++})
assert.equal(result.candidate,null);assert.equal(result.reason,'TIMED_OUT')
assert.equal(context.calls.length,2);assert.equal(waits,1)
}
})
test('ambiguous exact candidates and authentication or malformed replies stop without masking server errors',async()=>{
for(const response of [candidateResponse([exactCandidate,exactCandidate]),{http:401,body:{code:'401'}},candidateResponse({records:[exactCandidate]})]) {
const context=candidateContext([response]);let waits=0
const result=await awaitReceivingCandidate(context,'offline',exactProducer,{attempts:3,delay:async()=>waits++})
assert.equal(result.candidate,null);assert.equal(context.calls.length,1);assert.equal(waits,0)
assert.ok(['AMBIGUOUS','QUERY_FAILED'].includes(result.reason))
}
})
test('candidate waits are per invocation with no cross-mode cache and enforce exact scope/bounds',async()=>{
const context=candidateContext([candidateResponse([exactCandidate]),candidateResponse([])])
assert.ok((await awaitReceivingCandidate(context,'first',exactProducer,{attempts:1})).candidate)
assert.equal((await awaitReceivingCandidate(context,'second',exactProducer,{attempts:1})).candidate,null)
for(const producer of [{containerNo:'TSTU6403402'},{}])await assert.rejects(awaitReceivingCandidate(context,'offline',producer))
await assert.rejects(awaitReceivingCandidate(context,'offline',exactProducer,{attempts:31}))
assert.equal(context.calls.length,2)
})
test('single-container continuation retains malformed/unknown probes without requiring unshipped cancelled CHILD; default chain still blocks missing real other container', async () => {
const requests = [], gaps = [], checks = []
@@ -73,6 +120,28 @@ test('receiving completion freezes all raw balances when group MIN represents tw
assert.equal(stages.AFTER_COMPLETE.positions.length, 2)
})
test('unrelated canonical UNBOUND stock remains in all snapshots but new receiving still requires a real pallet', () => {
const { scope, stages } = receivingProofFixture()
const unboundPosition = { ...stages.AFTER_COMPLETE.positions[0], id: '301', balanceId: '30',
palletId: '0', palletNo: 'UNBOUND', availablePackages: 1 }
for (const snapshot of Object.values(stages)) {
const balance = { ...snapshot.balances[0], id: '30', inboundOrderId: '30',
inboundCode: 'UNBOUND-SOURCE', skuCode: 'UNRELATED', availablePackages: 1, inTransitPackages: 0 }
snapshot.balances.push(balance)
snapshot.httpGroups.push({ ...balance })
snapshot.positions.push({ ...unboundPosition })
snapshot.palletComposition.push({ ...unboundPosition })
assert.equal(receivingEvidenceValid(snapshot, scope), true)
}
assert.equal(receivingCompletionProof(scope, stages).passed, true)
stages.AFTER_COMPLETE.stockReceipts[0].warehousePalletId = '0'
assert.equal(receivingEvidenceValid(stages.AFTER_COMPLETE, scope), false)
const malformed = structuredClone(stages.BEFORE_COMPLETE)
malformed.positions[0].palletNo = 'UNKNOWN'
malformed.palletComposition[0].palletNo = 'UNKNOWN'
assert.equal(receivingEvidenceValid(malformed, scope), false)
})
test('receiving proof rejects missing raw source, wrong customer/warehouse, prefix source, missing bucket, ledger key, duplicate receipt and operation mismatch', () => {
const mutations = [
row => row.balances.pop(), row => row.positions[0].customerId = 'OTHER',
@@ -111,6 +180,21 @@ test('normalization is deterministic, preserves zero buckets, and excludes token
assert.equal(normalizeReceivingEvidence({ ...after, stockReceipts: undefined }), null)
})
test('reverse event recovery requires complete owned inventory evidence and every immutable fact unchanged', () => {
const { scope, stages } = receivingProofFixture()
const before = stages.AFTER_FRESH_KEY_REJECTION
assert.equal(receivingEventRecoveryInventoryUnchanged(scope, before, structuredClone(before)), true)
assert.equal(receivingEventRecoveryInventoryUnchanged(scope, before, null), false)
assert.equal(receivingEventRecoveryInventoryUnchanged({ ...scope, customerId: 'OTHER' }, before, before), false)
for (const mutate of [row => row.balances[0].availablePackages++, row => row.stockReceipts.pop(),
row => row.positions[0].warehouseLocationCode = 'ANOTHER', row => row.stockInLedger[0].sourceEventId = 'OTHER',
row => row.receivingOperations.push({ ...row.receivingOperations[0], id: '999' })]) {
const changed = structuredClone(before)
mutate(changed)
assert.equal(receivingEventRecoveryInventoryUnchanged(scope, before, changed), false)
}
})
test('missing read-only receipt/key reader is a blocker and retains an unproven boundary without inventing HTTP', async () => {
const checks = [], target = { stages: {} }
const context = { context: {}, addCoverageGap() {}, assert(m, n, r, passed) { checks.push(passed) },
@@ -166,7 +250,7 @@ test('physical OVERSEAS and dropship probes still require another real loaded pr
})
test('receiving selects one current booking and supplies mandatory booking identity', () => {
assert.match(source, /id\(item.sourceContainerBookingId\) === preferredBookingId/)
assert.match(source, /item.containerNo === containerNo && id\(item.sourceContainerBookingId\) === bookingId/)
assert.match(source, /matchingCandidates.length === 1/)
assert.match(source, /if \(!longId\(sourceContainerBookingId\)\)/)
assert.match(source, /sourceContainerBookingId=\$\{encodeURIComponent\(sourceContainerBookingId\)\}/)
@@ -314,7 +398,8 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.match(source, /standardFbaProducers = \(Array\.isArray\(matrix\?\.containers\) \? matrix\.containers : \[\]\)/)
assert.match(source, /item\?\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'/)
assert.match(source, /FBA-OPERATIONS/)
assert.match(source, /\.slice\(0, 3\)/)
assert.match(source, /FBA-ROUTE/)
assert.match(source, /\.slice\(0, 4\)/)
assert.match(source, /const producers = \[dropship, \.\.\.standardFbaProducers,/)
assert.doesNotMatch(source, /创建当前柜实收托盘|创建当前柜实收库位|async function createPallet/)
assert.match(source, /retained as acceptance evidence/)
@@ -417,7 +502,7 @@ test('real-device deployed FBA resolve A0404 is retained until server-side inbou
test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => {
const orderedTokens = [
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
'const candidates = await awaitReceivingCandidate', "'选择柜号并建立WAIT_SIGN会话'",
'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'",
'resolveGeneratedPallet(context, inbound)', "`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'",
@@ -464,7 +549,7 @@ test('missing OVERSEAS_WAREHOUSE_PDA_OPERATOR overseas authority is a deployment
assert.ok(steps.some((step) => step?.passed === false && step?.options?.deploymentBlocker))
})
test('empty candidate response cannot become a zero-business-request pass', async () => {
test('missing original producer identity blocks before candidate lookup and still cleans temporary identities', async () => {
const calls = []; const gaps = []; const steps = []
const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200,
body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' })
@@ -509,9 +594,9 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
addCoverageGap(...args) { gaps.push(args) },
}
await run(context)
assert.ok(calls.some((call) => call.requestPath.endsWith('/candidates')))
assert.equal(calls.some((call) => call.requestPath.endsWith('/candidates')),false)
assert.ok(steps.some((step) => step.name.includes('正常空态') && step.passed))
assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker))
assert.ok(steps.some((step) => step.name.includes('本轮原柜候选身份覆盖前置') && !step.passed && step.options.deploymentBlocker))
assert.equal(gaps.length, 1)
assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null)
assert.equal(context.createdResources.phase4OverseasPda.pickerMembershipIds.length, 2)