test(admin): verify PDA inventory snapshots

This commit is contained in:
2026-08-11 20:10:37 +08:00
parent 5e1e281773
commit 86488da6d0
3 changed files with 180 additions and 10 deletions
@@ -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',
}
}
@@ -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')
@@ -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, [