test: verify complete PDA batch history

This commit is contained in:
2026-08-25 13:59:58 +08:00
parent 98766e2b1a
commit 9093550615
5 changed files with 49 additions and 13 deletions
@@ -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=/)
})
@@ -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',
}
@@ -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')
@@ -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'/)
})
@@ -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', () => {