test(admin): verify PDA inventory snapshots
This commit is contained in:
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user