test: integrate reliable event recovery and receiving evidence
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user