test: integrate reliable event recovery and receiving evidence
This commit is contained in:
@@ -356,3 +356,5 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra
|
||||
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
|
||||
|
||||
`event-bridge-pending-recovery.mjs` 为纯离线证据校验器,文件名保留为索引入口;无事件桥调用或历史事件常量。合同覆盖 Compose DNS、三服务内部凭据版本一致、有限重试/DEAD、原事件摘要、ACK 丢失后 DUPLICATE 与单次业务效果。合成合同通过不代表环境故障演练通过;`phase4-cross-region-events` 保留真实重放证据阻断项。环境验收需本轮 marker 事件逐次投递记录、Inbox 与业务计数;仅使用受保护 `event-outbox-operations.mjs` 对 DEAD 原事件执行另行授权重试,不重放 SENT,不改表或伪造新事件。
|
||||
|
||||
`direct-delivery-replay-proof.mjs` 是事件模块的内部证据校验器,不是独立执行入口。要求本轮原装柜事件的 ID/hash、12 次失败至 DEAD、人工恢复审计、持久 Inbox 及 DUPLICATE、恢复前后业务订单身份集合零新增和环境恢复同时成立;单纯重复 GET 或历史故障报告不能满足条件。离线合同为 `direct-delivery-replay-proof-contract.test.mjs`。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { validDirectDeliveryReplayProof } from './direct-delivery-replay-proof.mjs'
|
||||
|
||||
const eventId = 'd54a246f-c71c-462f-ab5b-a44f0e23d29a'
|
||||
const payloadHash = `sha256:${'a'.repeat(64)}`
|
||||
const scope = { marker: 'current-run', customerId: '7', loadingPlanId: '99', containerBookingId: '66' }
|
||||
function proof() {
|
||||
return { ...scope, readOnly: true, restored: true,
|
||||
producer: { eventId, payloadHash, entryId: '88', publishStatus: 'PUBLISHED', eventType: 'FirstLegLoadingPlanCompleted' },
|
||||
consumer: { eventId, payloadHash, status: 'PROCESSED', count: 1 },
|
||||
attempts: Array.from({ length: 13 }, (_, i) => ({ attemptId: `attempt-${i}`, eventId, entryId: '88',
|
||||
targetName: 'lastleg-service/internal/lastleg/events', outcome: i === 12 ? 'DUPLICATE' : i === 11 ? 'DEAD' : 'RETRY' })),
|
||||
proxyRecords: Array.from({ length: 13 }, (_, i) => ({ eventId, payloadHash, matched: true,
|
||||
outcome: i === 0 ? 'APPLIED' : 'DUPLICATE', errorCode: i === 12 ? null : 'ACK_LOSS_INJECTED' })),
|
||||
retryAudit: { eventId, payloadHash, entryId: '88', previousFailures: 12, requestId: 'retry-1' },
|
||||
beforeRetry: { eventId, payloadHash, publishStatus: 'DEAD', failedAttempts: 12, businessOrderIds: ['11', '12'] },
|
||||
afterRetry: { eventId, payloadHash, publishStatus: 'PUBLISHED', businessOrderIds: ['12', '11'] } }
|
||||
}
|
||||
test('only current original event finite failure, authenticated recovery and persistent duplicate consumption together pass', () => {
|
||||
assert.equal(validDirectDeliveryReplayProof(proof(), scope), true)
|
||||
})
|
||||
test('old evidence, GET stability, incomplete attempts, hash drift and business duplication fail closed', () => {
|
||||
const changes = [
|
||||
p => { p.marker = 'old' }, p => { p.attempts = [] }, p => { p.proxyRecords = [] },
|
||||
p => { p.consumer.count = 2 }, p => { p.attempts[12].outcome = 'APPLIED' },
|
||||
p => { p.retryAudit.payloadHash = `sha256:${'b'.repeat(64)}` },
|
||||
p => { p.afterRetry.businessOrderIds.push('13') }, p => { p.restored = false },
|
||||
p => { p.attempts[11].outcome = 'RETRY' }, p => { p.beforeRetry.failedAttempts = 11 },
|
||||
p => { p.attempts[12].targetName = 'lastleg-service/internal/finance/events' },
|
||||
]
|
||||
for (const change of changes) {
|
||||
const p = proof(); change(p)
|
||||
assert.equal(validDirectDeliveryReplayProof(p, scope), false)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// This checks live, scoped evidence supplied by the TEST fault executor.
|
||||
// It never accepts historical reports or initiates event replay itself.
|
||||
const identifier = value => typeof value === 'string' && /^[1-9][0-9]{0,19}$/.test(value)
|
||||
const eventIdentifier = value => typeof value === 'string'
|
||||
&& /^(?:[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26})$/i.test(value)
|
||||
const hash = value => typeof value === 'string' && /^sha256:[0-9a-f]{64}$/i.test(value)
|
||||
const sortedIds = rows => [...rows].sort()
|
||||
|
||||
export function validDirectDeliveryReplayProof(proof, scope) {
|
||||
if (!proof || proof.readOnly !== true || proof.marker !== scope.marker
|
||||
|| !identifier(scope.loadingPlanId) || !identifier(scope.customerId)
|
||||
|| proof.loadingPlanId !== scope.loadingPlanId || proof.customerId !== scope.customerId
|
||||
|| proof.containerBookingId !== scope.containerBookingId) return false
|
||||
const { producer, consumer, attempts, proxyRecords, beforeRetry, afterRetry, retryAudit } = proof
|
||||
if (!eventIdentifier(producer?.eventId) || !hash(producer?.payloadHash)
|
||||
|| !identifier(producer?.entryId) || producer.publishStatus !== 'PUBLISHED'
|
||||
|| producer.eventType !== 'FirstLegLoadingPlanCompleted'
|
||||
|| consumer?.eventId !== producer.eventId || consumer?.payloadHash !== producer.payloadHash
|
||||
|| consumer?.status !== 'PROCESSED' || consumer?.count !== 1) return false
|
||||
if (!Array.isArray(attempts) || attempts.length !== 13
|
||||
|| new Set(attempts.map(row => row.attemptId)).size !== attempts.length
|
||||
|| attempts.some(row => !row.attemptId || row.eventId !== producer.eventId
|
||||
|| row.entryId !== producer.entryId || row.targetName !== 'lastleg-service/internal/lastleg/events')
|
||||
|| attempts.at(-1)?.outcome !== 'DUPLICATE'
|
||||
|| attempts[11]?.outcome !== 'DEAD'
|
||||
|| attempts.slice(0, 11).some(row => row.outcome !== 'RETRY')) return false
|
||||
if (!Array.isArray(proxyRecords) || proxyRecords.length !== 13
|
||||
|| proxyRecords.some(row => row.matched !== true || row.eventId !== producer.eventId
|
||||
|| row.payloadHash !== producer.payloadHash)
|
||||
|| !proxyRecords.some(row => row.outcome === 'APPLIED' && row.errorCode === 'ACK_LOSS_INJECTED')
|
||||
|| !proxyRecords.some(row => row.outcome === 'DUPLICATE' && row.errorCode === 'ACK_LOSS_INJECTED')) return false
|
||||
if (retryAudit?.eventId !== producer.eventId || retryAudit?.payloadHash !== producer.payloadHash
|
||||
|| retryAudit?.entryId !== producer.entryId || !retryAudit.requestId
|
||||
|| retryAudit.previousFailures !== 12 || beforeRetry?.publishStatus !== 'DEAD'
|
||||
|| beforeRetry?.failedAttempts !== 12 || afterRetry?.publishStatus !== 'PUBLISHED'
|
||||
|| beforeRetry.eventId !== producer.eventId || afterRetry.eventId !== producer.eventId
|
||||
|| beforeRetry.payloadHash !== producer.payloadHash || afterRetry.payloadHash !== producer.payloadHash) return false
|
||||
const before = beforeRetry.businessOrderIds, after = afterRetry.businessOrderIds
|
||||
return Array.isArray(before) && Array.isArray(after) && before.length > 0
|
||||
&& before.every(identifier) && after.every(identifier)
|
||||
&& new Set(before).size === before.length && new Set(after).size === after.length
|
||||
&& JSON.stringify(sortedIds(before)) === JSON.stringify(sortedIds(after))
|
||||
&& proof.restored === true
|
||||
}
|
||||
@@ -229,6 +229,10 @@ export async function run(context) {
|
||||
packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })),
|
||||
})
|
||||
const operationsBeforeSignature = JSON.stringify(operationsBefore || [])
|
||||
if (typeof context.context?.beforeLoadingComplete === 'function') {
|
||||
await context.context.beforeLoadingComplete({ marker: context.marker, customerId: tenantId,
|
||||
loadingPlanId, containerBookingId })
|
||||
}
|
||||
const completeAttempts = [1, 2].map((index) => ({
|
||||
body: {
|
||||
expectedTaskVersion: Number(taskBefore.version),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { validDirectDeliveryReplayProof } from './direct-delivery-replay-proof.mjs'
|
||||
|
||||
const MODULE = 'phase4-cross-region-events'
|
||||
const OPTIONS = { scenario: 'phase4-cross-region-events', deploymentBlocker: true }
|
||||
|
||||
@@ -139,7 +141,18 @@ export async function run(context) {
|
||||
success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null))
|
||||
&& traced(writtenBackAgain),
|
||||
evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS)
|
||||
blockingGap(context, '真实投递重试与同事件幂等证据',
|
||||
const reader = context.context?.directDeliveryReplayEvidenceReader
|
||||
if (typeof reader !== 'function') blockingGap(context, '真实投递重试与同事件幂等证据',
|
||||
'两次GET仅证明读取稳定;需按event-outbox-operations.mjs的受保护内部运维流程,在本轮事件上验证故障后有限重试/DEAD及同eventId+payloadHash重复消费,保存逐次尝试与业务计数证据。不得重放SENT、改表或伪造新事件。',
|
||||
'missing-direct-delivery-replay-evidence')
|
||||
const scope = { marker: context.marker, loadingPlanId: id(phase3.loadingPlanId),
|
||||
customerId: id(phase3.tenantId), containerBookingId: id(phase3.containerBookingId) }
|
||||
const proof = await reader(scope)
|
||||
context.createdResources.directDeliveryReplayProof = proof
|
||||
const valid = validDirectDeliveryReplayProof(proof, scope)
|
||||
context.assert(MODULE, '本轮原事件12次失败DEAD人工恢复DUPLICATE且业务零新增', null, valid,
|
||||
`loadingPlanId=${scope.loadingPlanId}, attempts=${proof?.attempts?.length || 0}, restored=${proof?.restored === true}`, OPTIONS)
|
||||
if (!valid) blockingGap(context, '真实投递重试证据不完整或身份漂移',
|
||||
'本轮事件身份/摘要、12次有限重试、DEAD、人工恢复审计、重复消费及业务计数和恢复直连必须同时核平',
|
||||
'invalid-direct-delivery-replay-evidence')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -99,7 +99,8 @@ export function receivingEvidenceValid(snapshot, scope) {
|
||||
|| !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)
|
||||
&& snapshot.balances.some(balance => balance.id === row.balanceId)
|
||||
&& ((longId(row.palletId) && nonBlank(row.palletNo)) || (row.palletId === '0' && row.palletNo === 'UNBOUND'))
|
||||
&& longId(row.warehouseLocationId) && nonBlank(row.warehouseLocationCode))
|
||||
|| !sameEvidence(snapshot.positions, snapshot.palletComposition)) return false
|
||||
if (!snapshot.balances.every(balance => RECEIVING_POSITION_BUCKETS.every(field =>
|
||||
@@ -116,6 +117,7 @@ export function receivingEvidenceValid(snapshot, scope) {
|
||||
|| !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)
|
||||
&& longId(row.warehousePalletId) && nonBlank(row.palletNo)
|
||||
&& 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) {
|
||||
@@ -220,6 +222,11 @@ export async function captureReceivingEvidence(context, scope, stage, target) {
|
||||
}
|
||||
}
|
||||
|
||||
export function receivingEventRecoveryInventoryUnchanged(scope, before, after) {
|
||||
return receivingEvidenceValid(before, scope) && receivingEvidenceValid(after, scope)
|
||||
&& sameEvidence(before, after)
|
||||
}
|
||||
|
||||
export function expandOverseasCartonCandidates(inbounds, producerCartonMarks = []) {
|
||||
const marks = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
|
||||
.map(id).filter(nonBlank)
|
||||
@@ -339,7 +346,17 @@ export async function createPicker(context, index, overseasWarehouseId, access)
|
||||
}
|
||||
context.addStep(MODULE, `海外PDA操作员${index}安全投影已收敛`, projectionResponse, projectionReady,
|
||||
`attempts=${projectionAttempt + 1}; ${trace(projectionResponse)}`, OPTIONS)
|
||||
return { membershipId, username, token: projectionReady ? token : null }
|
||||
return { membershipId, username, token: projectionReady ? token : null,
|
||||
refreshToken: login.body?.data?.refreshToken, password,
|
||||
userUpdate: { displayName: payload.displayName, phone, email: payload.email, tenantId: '0',
|
||||
roleIds: payload.roleIds, overseasWarehouseId, domesticWarehouseId: null } }
|
||||
}
|
||||
|
||||
export function pickerSessionHandoff(picker) {
|
||||
if (!picker) return null
|
||||
const { membershipId, username, token, refreshToken, password, userUpdate } = picker
|
||||
return { membershipId, username, token, refreshToken, password,
|
||||
userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined }
|
||||
}
|
||||
|
||||
export async function disablePicker(context, picker, index) {
|
||||
@@ -827,7 +844,37 @@ async function receiveDropship(context, token, producer, inbounds) {
|
||||
return accepted === expectedPending
|
||||
}
|
||||
|
||||
export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) {
|
||||
export async function awaitReceivingCandidate(context, token, producer, { attempts = 30, intervalMs = 1000,
|
||||
delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
||||
const containerNo = id(producer?.containerNo), bookingId = id(producer?.containerBookingId)
|
||||
if (!nonBlank(containerNo) || !longId(bookingId) || !Number.isInteger(attempts) || attempts < 1 || attempts > 30
|
||||
|| !Number.isInteger(intervalMs) || intervalMs < 0 || intervalMs > 1000) throw Error('Invalid exact receiving candidate scope or wait bound')
|
||||
const proof = { containerNo, sourceContainerBookingId:bookingId, attempts:[], outcome:'PENDING' }
|
||||
const resource = context.createdResources.phase4OverseasPda ||= {}
|
||||
;(resource.candidateWaitEvidence ||= []).push(proof)
|
||||
let result
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
result = await pda(context, token, 'GET', `查询本轮原柜候选收敛第${attempt}次`, '/api/pda/v1/last-leg/receiving-containers/candidates')
|
||||
const rows = Array.isArray(result.data) ? result.data : []
|
||||
const matchingCandidates = rows.filter(item => item.containerNo === containerNo && id(item.sourceContainerBookingId) === bookingId)
|
||||
proof.attempts.push({ attempt, http:result.response?.http, code:code(result.response), requestId:result.response?.requestId,
|
||||
traceId:result.response?.traceId, candidateCount:rows.length, exactMatches:matchingCandidates.length,
|
||||
sameNumberCandidates:rows.filter(item => item.containerNo === containerNo).map(item => ({
|
||||
containerNo:item.containerNo, sourceContainerBookingId:id(item.sourceContainerBookingId),
|
||||
operable:item.operable, blockReason:item.blockReason })) })
|
||||
if (!success(result.response) || !Array.isArray(result.data)) { proof.outcome='QUERY_FAILED'; break }
|
||||
if (matchingCandidates.length > 1) { proof.outcome='AMBIGUOUS'; break }
|
||||
if (matchingCandidates.length === 1 && matchingCandidates[0].operable === true) {
|
||||
proof.outcome='READY'
|
||||
return {...result,candidate:matchingCandidates[0]}
|
||||
}
|
||||
if (attempt < attempts) await delay(intervalMs)
|
||||
}
|
||||
if (proof.outcome === 'PENDING') proof.outcome='TIMED_OUT'
|
||||
return {...result,candidate:null,reason:proof.outcome}
|
||||
}
|
||||
|
||||
export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true, candidateWait = {}) {
|
||||
if (runAuthorizationProbes && context.token) {
|
||||
const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {})
|
||||
context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS)
|
||||
@@ -844,20 +891,19 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
|
||||
blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before)
|
||||
return
|
||||
}
|
||||
const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates')
|
||||
if (!Array.isArray(candidates.data) || candidates.data.length === 0) {
|
||||
blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response)
|
||||
return
|
||||
}
|
||||
const preferredContainer = id(preferredProducer?.containerNo)
|
||||
|| context.createdResources?.phase3Loading?.containerNo
|
||||
const preferredBookingId = id(preferredProducer?.containerBookingId)
|
||||
const matchingCandidates = candidates.data.filter(item => item.containerNo === preferredContainer
|
||||
&& (!preferredBookingId || id(item.sourceContainerBookingId) === preferredBookingId))
|
||||
const candidate = matchingCandidates.length === 1 ? matchingCandidates[0] : null
|
||||
const preferredBookingId = id(preferredProducer?.containerBookingId || context.createdResources?.phase3Loading?.containerBookingId)
|
||||
if (!nonBlank(preferredContainer) || !longId(preferredBookingId)) {
|
||||
blockerGap(context, '本轮原柜候选身份', '缺少本轮containerNo或containerBookingId,禁止仅按柜号匹配历史柜', 'producer-chain')
|
||||
return
|
||||
}
|
||||
const candidates = await awaitReceivingCandidate(context,token,
|
||||
{containerNo:preferredContainer,containerBookingId:preferredBookingId},candidateWait)
|
||||
const candidate = candidates.candidate
|
||||
if (!candidate) {
|
||||
blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`,
|
||||
'三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response)
|
||||
`本轮原柜候选未在有界等待内唯一可操作:${candidates.reason};禁止回退处理历史或其他柜`, 'producer-chain', candidates.response)
|
||||
return
|
||||
}
|
||||
const containerNo = id(candidate?.containerNo)
|
||||
@@ -1015,6 +1061,9 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
|
||||
const receivingProof = { scope, stages: {}, proof: null, status: 'UNPROVEN' }
|
||||
context.createdResources.phase4OverseasPda.receivingCompletionEvidence ||= {}
|
||||
context.createdResources.phase4OverseasPda.receivingCompletionEvidence[sourceContainerBookingId] = receivingProof
|
||||
if (typeof context.context.beforeReceivingComplete === 'function') {
|
||||
await context.context.beforeReceivingComplete(scope)
|
||||
}
|
||||
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)
|
||||
@@ -1033,6 +1082,20 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
|
||||
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)
|
||||
if (typeof context.context.afterReceivingComplete === 'function') {
|
||||
if (receivingProof.status !== 'VERIFIED') throw new Error('收货完成库存证据未闭合,禁止继续故障恢复验收')
|
||||
const eventRecovery = await context.context.afterReceivingComplete(scope)
|
||||
if (eventRecovery) {
|
||||
receivingProof.eventRecovery = eventRecovery
|
||||
const afterRecovery = await captureReceivingEvidence(context, scope, 'AFTER_EVENT_RECOVERY', receivingProof)
|
||||
const unchanged = eventRecovery.restored === true && receivingEventRecoveryInventoryUnchanged(scope,
|
||||
receivingProof.stages.AFTER_FRESH_KEY_REJECTION.evidence, afterRecovery)
|
||||
receivingProof.eventRecoveryInventoryUnchanged = unchanged
|
||||
context.assert(MODULE, '原卸柜事件DEAD恢复后库存位置托盘组成及不可变流水零漂移', null, unchanged,
|
||||
`bookingId=${sourceContainerBookingId}`, OPTIONS)
|
||||
if (!unchanged) throw new Error('卸柜事件恢复后库存证据变化或服务恢复未验证')
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1110,8 +1173,8 @@ export async function run(context) {
|
||||
}
|
||||
context.context.phase4OverseasPdaOperations = {
|
||||
marker: context.marker,
|
||||
primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null,
|
||||
secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null,
|
||||
primary: pickerSessionHandoff(primary),
|
||||
secondary: pickerSessionHandoff(secondary),
|
||||
overseasWarehouseId: sharedWarehouse.id,
|
||||
merchantToken,
|
||||
completedFixtures: [],
|
||||
@@ -1127,9 +1190,9 @@ export async function run(context) {
|
||||
const standardFbaProducers = (Array.isArray(matrix?.containers) ? matrix.containers : [])
|
||||
.filter((item) => item?.destinationType === 'FBA')
|
||||
.filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
|
||||
.filter((item) => /-P4-(?:FBA-STANDARD|FBA-SECOND|FBA-OPERATIONS)(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
||||
.filter((item) => /-P4-(?:FBA-STANDARD|FBA-SECOND|FBA-OPERATIONS|FBA-ROUTE)(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
|
||||
.sort((left, right) => String(left?.resourceKey).localeCompare(String(right?.resourceKey)))
|
||||
.slice(0, 3)
|
||||
.slice(0, 4)
|
||||
// The sealed dropship pallet proves the indivisible handover. A separate standard FBA
|
||||
// fixture supplies multiple cartons for reversible operations. The second standard FBA
|
||||
// fixture remains available for the multi-destination dispatch and delivery split proof.
|
||||
|
||||
Reference in New Issue
Block a user