From 909355061521f6e5ded9647d2ea1f15912f57f13 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:59:58 +0800 Subject: [PATCH] test: verify complete PDA batch history --- ...e3-loading-dynamic-final-contract.test.mjs | 5 +++++ .../admin-test-env-modules/phase3-loading.mjs | 20 ++++++++++++------- .../phase3-pda-convergence.mjs | 18 +++++++++++++---- ...-pda-pending-consistency-contract.test.mjs | 8 ++++++-- .../phase3-producer-chain-contract.test.mjs | 11 ++++++++++ 5 files changed, 49 insertions(+), 13 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 39cdbb7f6..e11b400de 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 @@ -45,6 +45,7 @@ test('default fixture publishes a second marker-owned inbound for the same-plan assert.match(entry, /orders-phase3-loading-incremental-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/) assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/) + assert.match(entry, /phase3LoadingIncrementalDetailCount = 4/) assert.match(entry, /phase3LoadingIncremental: incrementalFixture/) assert.match(entry, /marker: originalMarker/) assert.doesNotMatch(entry, /phase3LoadingIncremental[\s\S]{0,300}(history|fallback|latest)/i) @@ -74,6 +75,10 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/) + assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /expectedBatchOperationIds, incrementalInboundId/) + assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) + assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) assert.doesNotMatch(loading, /complete-before-downstream/) assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 19dc4aa1d..4a27a4019 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -566,6 +566,10 @@ export async function run(context) { const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) + const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128) + const partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128) + const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128) + const expectedBatchOperationIds = [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey] const batchPayload = { warehouseId, loadingPlanId, containerBookingId, expectedVersion: Number(claimed?.version), @@ -1021,6 +1025,8 @@ export async function run(context) { const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental const incrementalInboundId = id(incrementalFixture?.inboundId) + let skipBatchVerified = false + let partialBatchVerified = false let dynamicIncrementVerified = false if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) { context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false, @@ -1068,10 +1074,10 @@ export async function run(context) { expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken, items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version), reason: skipReason }], - }, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) }) + }, { 'Idempotency-Key': skipBatchKey }) const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS) const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id)) - context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, + skipBatchVerified = context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED' && Number(skippedTaskItem?.pickedPackageCount) === 0 && skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING', @@ -1125,10 +1131,10 @@ export async function run(context) { inventorySnapshotToken: partialInventory?.snapshotToken, items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1, releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }], - }, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) }) + }, { 'Idempotency-Key': partialBatchKey }) const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS) const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id)) - context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, + partialBatchVerified = context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2 && Number(partialTaskItem?.pickedPackageCount) === 1 && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', @@ -1226,7 +1232,6 @@ export async function run(context) { context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse, Number(dynamicCandidate?.availablePackageCount) >= 2, `availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) - if (Number(dynamicCandidate?.availablePackageCount) < 2) return if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) { const adjustResponse = await request(context, 'PATCH', `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, { @@ -1259,7 +1264,7 @@ export async function run(context) { inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken, items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount), releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }], - }, { 'Idempotency-Key': `${context.marker}-dynamic-pick`.slice(0, 128) }) + }, { 'Idempotency-Key': dynamicBatchKey }) const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS) context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse, id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW' @@ -1341,7 +1346,8 @@ export async function run(context) { atomicBatchVerified: awaitingReview && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified, inventoryMutationVerified, inventoryMutationCleanup, - dynamicIncrementVerified, incrementalInboundId, + skipBatchVerified, partialBatchVerified, dynamicIncrementVerified, + expectedBatchOperationIds, incrementalInboundId, pdaTaskVersion: Number(stagedTask?.version), marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW', } diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 40a7d541c..f76ab00e3 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -82,7 +82,9 @@ export async function run(context) { const batchOperations = Array.isArray(operations) ? operations.filter((item) => item.actionType === 'BATCH' && Boolean(item.operatorId) && Boolean(item.createdAt)) : [] - const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1 + const expectedBatchOperationIds = Array.isArray(fixture?.expectedBatchOperationIds) + ? fixture.expectedBatchOperationIds.map(id).filter(Boolean) : [] + const actualBatchOperationIds = batchOperations.map((item) => id(item.operationId)).filter(Boolean) context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) @@ -91,9 +93,13 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - batchOperations.length === expectedBatchCount - && new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount, - `expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS) + expectedBatchOperationIds.length === 4 + && new Set(expectedBatchOperationIds).size === 4 + && actualBatchOperationIds.length === 4 + && new Set(actualBatchOperationIds).size === 4 + && expectedBatchOperationIds.every((operationId) => actualBatchOperationIds.includes(operationId)) + && actualBatchOperationIds.every((operationId) => expectedBatchOperationIds.includes(operationId)), + `expectedBatchCount=${expectedBatchOperationIds.length}, actualBatchCount=${actualBatchOperationIds.length}, expectedIds=${expectedBatchOperationIds.join(',') || 'missing'}, actualIds=${actualBatchOperationIds.join(',') || 'missing'}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' }, @@ -127,6 +133,10 @@ export async function run(context) { context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false, 'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS) } + if (fixture?.skipBatchVerified !== true || fixture?.partialBatchVerified !== true) { + context.addStep(MODULE, 'SKIP 与 PARTIAL 独立原子批次均完成', null, false, + `skipBatchVerified=${fixture?.skipBatchVerified === true}, partialBatchVerified=${fixture?.partialBatchVerified === true};不能按普通 PICK 批次代替异常处理审计`, OPTIONS) + } context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', 'device') diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index 653a53ab0..96bb8c319 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -25,8 +25,12 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) - assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/) - assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/) + assert.match(convergence, /expectedBatchOperationIds\.length === 4/) + assert.match(convergence, /actualBatchOperationIds\.length === 4/) + assert.match(convergence, /new Set\(expectedBatchOperationIds\)\.size === 4/) + assert.match(convergence, /new Set\(actualBatchOperationIds\)\.size === 4/) + assert.match(convergence, /expectedBatchOperationIds\.every[\s\S]*actualBatchOperationIds\.every/) + assert.doesNotMatch(convergence, /dynamicIncrementVerified === true \? 2 : 1/) assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/) assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/) }) 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 8a5da135f..2f019a12c 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 @@ -406,6 +406,7 @@ test('preload and loading versions use immediate operation baselines and exact p }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { + const loading = source('./phase3-loading.mjs') const container = source('./phase3-container-loading.mjs') const convergence = source('./phase3-pda-convergence.mjs') assertOrder(container, [ @@ -426,6 +427,13 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au assert.match(container, /Boolean\(item\.operatorId\) && Boolean\(item\.createdAt\)/) assert.match(convergence, /PDA 原子批次完成审计包含 BATCH 操作/) assert.match(convergence, /item\.actionType === 'BATCH'/) + for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) { + assert.match(loading, new RegExp(key)) + } + assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) + assert.match(convergence, /expectedBatchOperationIds\.length === 4/) + assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/) }) test('phase3 loading proves mixed-line rollback across task, all items and operations', () => { @@ -493,6 +501,9 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/) assert.match(measurement, /items: draftItems/) assert.match(measurement, /expectedPackages === originalPackageCount/) + assert.match(loading, /skipBatchVerified = context\.assert/) + assert.match(loading, /partialBatchVerified = context\.assert/) + assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) }) test('phase3 loading fixture forces A0 through reversible public location status APIs', () => {