From cb78bc6b41ee375e90a0c14d74ebafc5df3615e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:58:59 +0800 Subject: [PATCH] test: cover partial and concurrent pda completion --- ...e3-loading-dynamic-final-contract.test.mjs | 23 ++++++++ .../phase3-loading-finalize.mjs | 52 ++++++++++++++++--- .../admin-test-env-modules/phase3-loading.mjs | 9 ++++ .../phase3-producer-chain-contract.test.mjs | 4 ++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 8e604f641..39cdbb7f6 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -142,3 +142,26 @@ test('HTTP finalizer preserves failure evidence and verifies the settled slice', assert.match(finalize, /traceId=\$\{completeResponse\.traceId \|\| 'missing'\}/) assert.match(finalize, /settlementMode = remainderSlice \? 'PARTIAL' : 'FULL'/) }) + +test('HTTP finalizer races distinct completion keys and persists exactly one terminal audit', () => { + ordered(finalize, [ + '读取结算前 PDA 操作历史基线', + 'const completeAttempts = [1, 2].map', + 'const concurrentCompleteResponses = await Promise.all', + '并发完成后回查 PDA 操作审计', + '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', + '订舱与五类证据等保留门禁齐备后完成装柜', + 'LOADED 完成请求 Idempotency-Key 重放保持终态', + '入库单终态 LOADED、版本递增且审计历史存在', + ]) + assert.match(finalize, /pda-final-complete-concurrent-\$\{index\}/) + assert.match(finalize, /p3-complete-concurrent-key-\$\{index\}/) + assert.match(finalize, /successfulCompleteIndexes\.length === 1/) + assert.match(finalize, /conflictedCompleteIndexes\.length === 1/) + assert.match(finalize, /codeOf\(response\) === 'A0409'/) + assert.match(finalize, /Boolean\(conflictedCompleteResponse\?\.requestId\)/) + assert.match(finalize, /Boolean\(conflictedCompleteResponse\?\.traceId\)/) + assert.match(finalize, /operationsAfterConcurrent\.length === operationsBefore\.length \+ 1/) + assert.match(finalize, /concurrentCompleteOperations\.length === 1/) + assert.match(finalize, /concurrentCompleteOperations\[0\]\?\.operationId === completeBody\.operationId/) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index c77b313ae..05c4fbb18 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -129,13 +129,50 @@ export async function run(context) { packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })), }) const operationsBeforeSignature = JSON.stringify(operationsBefore || []) - const completeBody = { - expectedTaskVersion: Number(taskBefore.version), - operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker, - } - const completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128) - const completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody, - { 'Idempotency-Key': completeKey }) + const completeAttempts = [1, 2].map((index) => ({ + body: { + expectedTaskVersion: Number(taskBefore.version), + operationId: `${context.marker}-pda-final-complete-concurrent-${index}`.slice(0, 128), + remark: context.marker, + }, + key: `${context.marker}-p3-complete-concurrent-key-${index}`.slice(0, 128), + })) + const concurrentCompleteResponses = await Promise.all(completeAttempts.map((attempt) => + request(context, 'POST', `${taskPath}/complete`, attempt.body, + { 'Idempotency-Key': attempt.key }))) + const successfulCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) => + response.http === 200 && codeOf(response) === '200' ? [index] : []) + const conflictedCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) => + response.http === 200 && codeOf(response) === 'A0409' ? [index] : []) + const conflictedCompleteResponse = conflictedCompleteIndexes.length === 1 + ? concurrentCompleteResponses[conflictedCompleteIndexes[0]] : null + const winningCompleteIndex = successfulCompleteIndexes[0] ?? -1 + const completeResponse = winningCompleteIndex >= 0 + ? concurrentCompleteResponses[winningCompleteIndex] : concurrentCompleteResponses[0] + const completeBody = winningCompleteIndex >= 0 + ? completeAttempts[winningCompleteIndex].body : completeAttempts[0].body + const completeKey = winningCompleteIndex >= 0 + ? completeAttempts[winningCompleteIndex].key : completeAttempts[0].key + const operationsAfterConcurrentResponse = await context.request('GET', `${taskPath}/operations`) + const operationsAfterConcurrent = context.apiSuccess(MODULE, + '并发完成后回查 PDA 操作审计', operationsAfterConcurrentResponse, '', OPTIONS) + const baselineCompleteOperationIds = new Set((operationsBefore || []) + .filter((item) => item?.actionType === 'COMPLETE_LOADING').map((item) => String(item?.operationId || ''))) + const concurrentCompleteOperations = (operationsAfterConcurrent || []).filter((item) => + item?.actionType === 'COMPLETE_LOADING' + && completeAttempts.some((attempt) => attempt.body.operationId === item?.operationId) + && !baselineCompleteOperationIds.has(String(item?.operationId || ''))) + const concurrentCompleteVerified = successfulCompleteIndexes.length === 1 + && conflictedCompleteIndexes.length === 1 + && Boolean(conflictedCompleteResponse?.requestId) && Boolean(conflictedCompleteResponse?.traceId) + && Array.isArray(operationsAfterConcurrent) && Array.isArray(operationsBefore) + && operationsAfterConcurrent.length === operationsBefore.length + 1 + && concurrentCompleteOperations.length === 1 + && concurrentCompleteOperations[0]?.operationId === completeBody.operationId + && id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt) + context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', + completeResponse, concurrentCompleteVerified, + `results=${concurrentCompleteResponses.map((response) => `${response.http}/${codeOf(response)}`).join(',')}, success=${successfulCompleteIndexes.length}, conflict=${conflictedCompleteIndexes.length}, completeAudits=${concurrentCompleteOperations.length}, winner=${completeBody.operationId}`, OPTIONS) if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') { context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false, `expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS) @@ -166,6 +203,7 @@ export async function run(context) { `taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS) return } + if (!concurrentCompleteVerified) return const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS) context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse, completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2fd8d05f9..19dc4aa1d 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1133,6 +1133,15 @@ export async function run(context) { && Number(partialTaskItem?.pickedPackageCount) === 1 && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', `itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS) + const exceptionOverviewResponse = await context.request('GET', + `/api/platform/v1/containers/${encodeURIComponent(containerBookingId)}/overview`) + const exceptionOverview = context.apiSuccess(MODULE, + '回查柜次 PDA 异常原因与已解决进度', exceptionOverviewResponse, '', OPTIONS) + context.assert(MODULE, 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', exceptionOverviewResponse, + exceptionOverview?.pda?.exceptionReasons?.includes?.(skipReason) + && Number(exceptionOverview?.pda?.unresolvedItemCount) === 0 + && exceptionOverview?.pda?.workflowStatus === 'AWAITING_REVIEW', + `exceptionReasons=${JSON.stringify(exceptionOverview?.pda?.exceptionReasons || [])}, unresolved=${exceptionOverview?.pda?.unresolvedItemCount ?? 'missing'}, workflow=${exceptionOverview?.pda?.workflowStatus || 'missing'}`, OPTIONS) } if (dynamicCandidate?.inboundDetailId) { const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index d54cf72d4..6150e4891 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -471,6 +471,8 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea '未解决异常完成失败后 task/items/operation 全部零写入', '合法 PARTIAL 解决未完成明细', '部分拣货数量守恒并恢复最终可确认状态', + '回查柜次 PDA 异常原因与已解决进度', + 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/) @@ -481,6 +483,8 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/) assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/) assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/) + assert.match(loading, /exceptionOverview\?\.pda\?\.exceptionReasons\?\.includes\?\.\(skipReason\)/) + assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/) assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) })