diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index e877f5ac2..44e6e41aa 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -63,6 +63,27 @@ function fixture(context) { || context.createdResources?.orders } +function inventorySignature(snapshot) { + return JSON.stringify((snapshot?.inbounds || []).map((inbound) => ({ + inboundId: id(inbound?.inboundId), + availablePackageCount: Number(inbound?.availablePackageCount), + resources: (inbound?.resources || []).map((resource) => ({ + reservationId: id(resource?.reservationId), occupancyId: id(resource?.occupancyId), + palletId: id(resource?.palletId), locationId: id(resource?.locationId), + sharedOverflow: resource?.sharedOverflow === true, + availablePackageCount: Number(resource?.availablePackageCount), + })), + }))) +} + +function operationsSignature(operations) { + return JSON.stringify((operations || []).map((operation) => ({ + id: id(operation?.id), operationId: operation?.operationId || null, + actionType: operation?.actionType || null, operatorId: id(operation?.operatorId), + createdAt: operation?.createdAt || null, + }))) +} + export async function run(context) { const source = fixture(context) const tenantId = id(source?.tenantId) @@ -210,13 +231,33 @@ export async function run(context) { const inventoryResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) - context.assert(MODULE, '实时库存快照包含当前入库单和稳定令牌', inventoryResponse, - typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 - && inventory?.inbounds?.some?.((item) => id(item.inboundId) === inboundId - && Number(item.availablePackageCount) >= 1), - `token=${inventory?.snapshotToken ? 'present' : 'missing'}, inboundCount=${inventory?.inbounds?.length ?? 0}`, OPTIONS) + const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId) + const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId + && Number(inventory?.taskVersion) === Number(claimed?.version) + && inventory?.containerNo === containerNo && id(inventory?.warehouseId) === warehouseId + && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string' + && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 + && (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId) + && id(resource?.occupancyId) && id(resource?.palletId) && id(resource?.locationId) + && Number(resource?.availablePackageCount) >= 0) + && Number(inventoryInbound?.availablePackageCount) === (inventoryInbound?.resources || []) + .reduce((total, resource) => total + Number(resource?.availablePackageCount || 0), 0) + context.assert(MODULE, '实时库存快照归属、版本、资源数量和稳定令牌完整', inventoryResponse, + inventoryIdentityVerified, + `taskId=${id(inventory?.taskId) || 'missing'}, taskVersion=${inventory?.taskVersion ?? 'missing'}, warehouseId=${id(inventory?.warehouseId) || 'missing'}, token=${inventory?.snapshotToken ? 'present' : 'missing'}, available=${inventoryInbound?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!inventory?.snapshotToken) return + const repeatedInventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const repeatedInventory = context.apiSuccess(MODULE, '未发生库存变化时重复读取实时库存快照', repeatedInventoryResponse, '', OPTIONS) + const inventorySnapshotVerified = inventoryIdentityVerified + && repeatedInventory?.snapshotToken === inventory.snapshotToken + && Number(repeatedInventory?.taskVersion) === Number(inventory.taskVersion) + && inventorySignature(repeatedInventory) === inventorySignature(inventory) + context.assert(MODULE, '未变化库存返回稳定 token、版本和数量快照', repeatedInventoryResponse, + inventorySnapshotVerified, + `sameToken=${repeatedInventory?.snapshotToken === inventory.snapshotToken}, taskVersion=${repeatedInventory?.taskVersion ?? 'missing'}`, OPTIONS) + const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const batchPayload = { @@ -273,6 +314,15 @@ export async function run(context) { customerIsolatedBatch.http === 403 && codeOf(customerIsolatedBatch) === 'A0403' && Boolean(customerIsolatedBatch.requestId) && Boolean(customerIsolatedBatch.traceId), `HTTP=${customerIsolatedBatch.http}, code=${codeOf(customerIsolatedBatch)}, requestId=${customerIsolatedBatch.requestId || 'missing'}, traceId=${customerIsolatedBatch.traceId || 'missing'}`, OPTIONS) + const customerIsolatedSnapshot = merchantToken + ? await request(context, 'GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`, + undefined, { Authorization: `Bearer ${merchantToken}` }) + : merchantLogin + context.addStep(MODULE, '客户身份无权读取平台 PDA 实时库存快照', customerIsolatedSnapshot, + customerIsolatedSnapshot.http === 403 && codeOf(customerIsolatedSnapshot) === 'A0403' + && Boolean(customerIsolatedSnapshot.requestId) && Boolean(customerIsolatedSnapshot.traceId), + `HTTP=${customerIsolatedSnapshot.http}, code=${codeOf(customerIsolatedSnapshot)}, requestId=${customerIsolatedSnapshot.requestId || 'missing'}, traceId=${customerIsolatedSnapshot.traceId || 'missing'}`, OPTIONS) const currentAssigneeId = Number(claimed?.assignment?.assigneeId) const reassigned = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/assign`, { @@ -296,7 +346,92 @@ export async function run(context) { id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId), `assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS) - const canonicalPick = await request(context, 'POST', batchPath, batchPayload, + let inventoryMutationVerified = false + let inventoryMutationCleanup = null + let effectiveBatchPayload = batchPayload + const overflowResource = inventoryInbound?.resources?.find?.((resource) => + resource?.sharedOverflow === true && id(resource?.occupancyId)) + if (overflowResource) { + const mutationSuffix = context.marker.replace(/\D/g, '').slice(-10) + const targetLocationResponse = await context.request('POST', '/api/platform/v1/locations?tenantId=0', { + warehouseId, zoneCode: `SNAP-${mutationSuffix.slice(-6)}`, zoneName: '库存快照验收区', + locationCode: `SNAP-${mutationSuffix}`, locationName: '库存快照换绑库位', + locationType: 'PALLET', maxWeightKg: 1000, status: 'AVAILABLE', + remark: `${context.marker}-inventory-snapshot-mutation`, + }) + const targetLocationId = id(context.apiSuccess(MODULE, '创建库存快照变化目标库位', + targetLocationResponse, '', OPTIONS)) + if (targetLocationId) { + inventoryMutationCleanup = { + cleaned: false, targetLocationId, + reason: '目标库位在真实换绑后属于活动占用,公开删除接口会拒绝,不伪报已清理', + } + } + const beforeMutationTaskResponse = await context.request('GET', + `${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const beforeMutationTask = context.apiSuccess(MODULE, '库存变化前读取 PDA 任务基线', beforeMutationTaskResponse, '', OPTIONS) + const beforeMutationOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const beforeMutationOperations = context.apiSuccess(MODULE, '库存变化前读取 PDA 操作历史基线', + beforeMutationOperationsResponse, '', OPTIONS) + const rebindResponse = targetLocationId + ? await request(context, 'POST', + `/api/platform/v1/locations/temporary-occupancies/${encodeURIComponent(id(overflowResource.occupancyId))}/rebind`, { + targetLocationId, idempotencyKey: `${context.marker}-snapshot-rebind`.slice(0, 128), + reason: `${context.marker}-inventory-changed`, + }) + : targetLocationResponse + const rebound = context.apiSuccess(MODULE, '通过公开 A0 换绑接口制造真实库存变化', rebindResponse, '', OPTIONS) + context.assert(MODULE, '真实库存换绑保持同仓且位置发生变化', rebindResponse, + id(rebound?.occupancyId) === id(overflowResource.occupancyId) + && id(rebound?.previousLocationId) === id(overflowResource.locationId) + && id(rebound?.locationId) === targetLocationId, + `occupancyId=${id(rebound?.occupancyId) || 'missing'}, previousLocationId=${id(rebound?.previousLocationId) || 'missing'}, locationId=${id(rebound?.locationId) || 'missing'}`, OPTIONS) + + const staleInventoryBatch = await request(context, 'POST', batchPath, batchPayload, + { 'Idempotency-Key': `${context.marker}-bridge-batch-stale-inventory`.slice(0, 128) }) + context.addStep(MODULE, '真实库存变化后旧 snapshot token 提交返回冲突', staleInventoryBatch, + staleInventoryBatch.http === 200 && codeOf(staleInventoryBatch) === 'A0409' + && String(staleInventoryBatch.body?.message || '').includes('库存已变化'), + `HTTP=${staleInventoryBatch.http}, code=${codeOf(staleInventoryBatch)}, message=${staleInventoryBatch.body?.message || 'missing'}`, OPTIONS) + + const afterStaleTaskResponse = await context.request('GET', + `${pdaBase}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const afterStaleTask = context.apiSuccess(MODULE, '旧库存快照冲突后回查 PDA 任务', afterStaleTaskResponse, '', OPTIONS) + const afterStaleOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const afterStaleOperations = context.apiSuccess(MODULE, '旧库存快照冲突后回查 PDA 操作历史', + afterStaleOperationsResponse, '', OPTIONS) + const beforeItem = beforeMutationTask?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) + const afterItem = afterStaleTask?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) + context.assert(MODULE, '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', afterStaleTaskResponse, + Number(afterStaleTask?.version) === Number(beforeMutationTask?.version) + && afterItem?.itemStatus === beforeItem?.itemStatus + && Number(afterItem?.pickedPackageCount) === Number(beforeItem?.pickedPackageCount) + && Number(afterItem?.version) === Number(beforeItem?.version) + && operationsSignature(afterStaleOperations) === operationsSignature(beforeMutationOperations), + `taskVersion=${afterStaleTask?.version ?? 'missing'}, itemStatus=${afterItem?.itemStatus || 'missing'}, picked=${afterItem?.pickedPackageCount ?? 'missing'}, operations=${afterStaleOperations?.length ?? 'missing'}`, OPTIONS) + + const refreshedInventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const refreshedInventory = context.apiSuccess(MODULE, '库存变化后刷新 PDA 实时库存快照', + refreshedInventoryResponse, '', OPTIONS) + const refreshedInbound = refreshedInventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId) + inventoryMutationVerified = refreshedInventory?.snapshotToken !== inventory.snapshotToken + && Number(refreshedInventory?.taskVersion) === Number(inventory.taskVersion) + && refreshedInbound?.resources?.some?.((resource) => id(resource.occupancyId) === id(overflowResource.occupancyId) + && id(resource.locationId) === targetLocationId) + context.assert(MODULE, '刷新快照返回新 token、同任务版本和变更后库存', refreshedInventoryResponse, + inventoryMutationVerified, + `tokenChanged=${refreshedInventory?.snapshotToken !== inventory.snapshotToken}, taskVersion=${refreshedInventory?.taskVersion ?? 'missing'}, targetLocation=${targetLocationId || 'missing'}`, OPTIONS) + if (refreshedInventory?.snapshotToken) { + effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken } + } + } else { + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', + `inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`, + 'fixture') + } + + const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload, { 'Idempotency-Key': batchKey }) const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS) const directPdaCompletion = pickedCanonical?.taskStatus === 'COMPLETED' @@ -305,7 +440,7 @@ export async function run(context) { directPdaCompletion && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'), `taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS) - const canonicalBatchReplay = await request(context, 'POST', batchPath, batchPayload, + const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload, { 'Idempotency-Key': batchKey }) const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200' && id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId @@ -527,6 +662,8 @@ export async function run(context) { loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified, + inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified, + inventoryMutationVerified, inventoryMutationCleanup, marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED', } } diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index fe84e9f13..3e76e81e1 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -61,9 +61,17 @@ export async function run(context) { '原子 batch 端点已部署,但本轮合法整批提交或同键重放未通过真实 HTTP 断言;保留真实失败证据', 'deployment-blocker') } - context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存查询与提交前库存快照校验', - '当前 PDA Controller/OpenAPI 未公开实时库存查询或库存版本快照端点,不能用智能装柜候选列表伪装库存契约', - 'contract-gap') + if (fixture?.inventorySnapshotVerified !== true) { + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存快照身份、稳定性与合法 token 提交', + 'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证', + 'deployment-blocker') + } + if (fixture?.inventoryMutationVerified !== true + && !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) { + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', + 'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化', + 'fixture') + } context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', 'device') 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 91a9ce000..8c288f7ad 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 @@ -93,6 +93,31 @@ test('bridge loading starts through a real PDA batch before preload confirmation assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === 2/) }) +test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => { + const loading = source('./phase3-loading.mjs') + assertOrder(loading, [ + '读取 PDA 提交前实时库存快照', + '未发生库存变化时重复读取实时库存快照', + '客户身份无权读取平台 PDA 实时库存快照', + 'const overflowResource =', + '通过公开 A0 换绑接口制造真实库存变化', + '真实库存变化后旧 snapshot token 提交返回冲突', + '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', + '库存变化后刷新 PDA 实时库存快照', + 'const canonicalPick = await request', + 'const canonicalBatchReplay = await request', + ]) + assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/) + assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/) + assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/) + assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/) + + const convergence = source('./phase3-pda-convergence.mjs') + assert.doesNotMatch(convergence, /未公开实时库存查询或库存版本快照端点/) + assert.match(convergence, /fixture\?\.inventorySnapshotVerified !== true/) + assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/) +}) + test('customs provisions released booking and completed truck for the selected loading container', () => { const value = source('./phase3-customs.mjs') assertOrder(value, [