test: verify pallet block settlement over HTTP
This commit is contained in:
@@ -78,6 +78,13 @@ async function request(context, method, path, body, headers = {}) {
|
|||||||
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function blockResourceIdentity(resource) {
|
||||||
|
return JSON.stringify({
|
||||||
|
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
|
||||||
|
reservationId: id(resource?.reservationId), locationId: id(resource?.locationId),
|
||||||
|
sharedOverflow: resource?.sharedOverflow === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) {
|
async function waitForCompletion(context, taskDetailPath, operationsPath, attempts = 90) {
|
||||||
let taskResponse = null
|
let taskResponse = null
|
||||||
@@ -258,6 +265,59 @@ export async function run(context) {
|
|||||||
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
|
&& item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'),
|
||||||
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
|
`status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS)
|
||||||
|
|
||||||
|
const expectedBlocks = [...(fixture.inventoryBlockSnapshots || []), fixture.partialBlockSnapshot]
|
||||||
|
.filter((item) => item?.inboundId && Array.isArray(item?.resources) && item.resources.length > 0)
|
||||||
|
.filter((item, index, all) => all.findIndex((candidate) => id(candidate.inboundId) === id(item.inboundId)) === index)
|
||||||
|
const releasedOrTransferredBlocks = []
|
||||||
|
for (const expected of expectedBlocks) {
|
||||||
|
const sourceBlockResponse = await context.request('GET',
|
||||||
|
`/api/platform/inbounds/${encodeURIComponent(id(expected.inboundId))}?tenantId=${encodeURIComponent(tenantId)}`)
|
||||||
|
const sourceBlock = context.apiSuccess(MODULE, '完成后回查来源入仓单整票块状态', sourceBlockResponse, '', OPTIONS)
|
||||||
|
const expectedIdentity = expected.resources.map(blockResourceIdentity).sort()
|
||||||
|
const sourceLocations = sourceBlock?.palletLocations || []
|
||||||
|
const sourceIdentity = sourceLocations.map(blockResourceIdentity).sort()
|
||||||
|
const statuses = new Set(sourceLocations.map((item) => item?.allocationStatus))
|
||||||
|
const transferred = statuses.size === 1 && statuses.has('TRANSFERRED')
|
||||||
|
const released = statuses.size === 1 && statuses.has('RELEASED')
|
||||||
|
let inheritedByRemainder = false
|
||||||
|
if (transferred) {
|
||||||
|
const sourceCode = String(sourceBlock?.summary?.inboundCode || expected.inboundCode || '').trim()
|
||||||
|
const remainderPageResponse = await context.request('GET',
|
||||||
|
`/api/platform/inbounds?tenantId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(`${sourceCode}-`)}&page=1&size=200`)
|
||||||
|
const remainderPage = context.apiSuccess(MODULE, '查询部分拣货生成的余量入仓单', remainderPageResponse, '', OPTIONS)
|
||||||
|
for (const row of remainderPage?.records || []) {
|
||||||
|
if (id(row?.id) === id(expected.inboundId)) continue
|
||||||
|
const remainderResponse = await context.request('GET',
|
||||||
|
`/api/platform/inbounds/${encodeURIComponent(id(row?.id))}?tenantId=${encodeURIComponent(tenantId)}`)
|
||||||
|
const remainder = remainderResponse?.http === 200 && codeOf(remainderResponse) === '200'
|
||||||
|
? remainderResponse.body?.data : null
|
||||||
|
const locations = remainder?.palletLocations || []
|
||||||
|
if (locations.length > 0
|
||||||
|
&& locations.every((item) => item?.allocationStatus === 'CONFIRMED')
|
||||||
|
&& JSON.stringify(locations.map(blockResourceIdentity).sort()) === JSON.stringify(expectedIdentity)) {
|
||||||
|
inheritedByRemainder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const identityStable = JSON.stringify(sourceIdentity) === JSON.stringify(expectedIdentity)
|
||||||
|
const verified = identityStable && (released || (transferred && inheritedByRemainder))
|
||||||
|
context.assert(MODULE, '整票块释放或原身份转移给余量入仓单', sourceBlockResponse, verified,
|
||||||
|
`inboundId=${id(expected.inboundId)}, blockId=${expected.resources[0]?.blockId || 'missing'}, statuses=${[...statuses].join(',') || 'missing'}, identityStable=${identityStable}, inheritedByRemainder=${inheritedByRemainder}`, OPTIONS)
|
||||||
|
releasedOrTransferredBlocks.push({ inboundId: id(expected.inboundId), released, transferred, verified })
|
||||||
|
}
|
||||||
|
const completedInventoryResponse = await context.request('GET',
|
||||||
|
`${taskPath}/inventory-snapshot?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`)
|
||||||
|
const completedInventory = context.apiSuccess(MODULE, '完成后回查 PDA 实时库存快照', completedInventoryResponse, '', OPTIONS)
|
||||||
|
const completedInventoryReleased = expectedBlocks.length > 0
|
||||||
|
&& expectedBlocks.every((expected) => {
|
||||||
|
const current = completedInventory?.inbounds?.find?.((item) => id(item?.inboundId) === id(expected.inboundId))
|
||||||
|
return Number(current?.availablePackageCount || 0) === 0 && (current?.resources || []).length === 0
|
||||||
|
})
|
||||||
|
context.assert(MODULE, '完成后的来源整票块不再作为 PDA 可用资源', completedInventoryResponse,
|
||||||
|
completedInventoryReleased,
|
||||||
|
`expectedBlocks=${expectedBlocks.length}, available=${(completedInventory?.inbounds || []).reduce((sum, item) => sum + Number(item?.availablePackageCount || 0), 0)}`, OPTIONS)
|
||||||
|
|
||||||
const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`
|
const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`
|
||||||
const slicesResponse = await context.request('GET', slicesPath)
|
const slicesResponse = await context.request('GET', slicesPath)
|
||||||
const slices = context.apiSuccess(MODULE, '结算后回查柜次实际履约分片', slicesResponse, '', OPTIONS)
|
const slices = context.apiSuccess(MODULE, '结算后回查柜次实际履约分片', slicesResponse, '', OPTIONS)
|
||||||
@@ -427,6 +487,8 @@ export async function run(context) {
|
|||||||
...buildFinalizeResource(fixture, finalData, completeResponse),
|
...buildFinalizeResource(fixture, finalData, completeResponse),
|
||||||
settlementMode,
|
settlementMode,
|
||||||
settledOrderId: id(loadedSlice?.childOrderId) || orderId,
|
settledOrderId: id(loadedSlice?.childOrderId) || orderId,
|
||||||
|
inventoryBlockSettlementVerified: expectedBlocks.length > 0
|
||||||
|
&& releasedOrTransferredBlocks.every((item) => item.verified) && completedInventoryReleased,
|
||||||
}
|
}
|
||||||
const producer = {
|
const producer = {
|
||||||
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
|
marker: context.context?.phase4ProducerMatrixMarker || context.marker,
|
||||||
|
|||||||
@@ -645,6 +645,20 @@ export async function run(context) {
|
|||||||
context.assert(MODULE, '未变化库存返回稳定 token、版本和数量快照', repeatedInventoryResponse,
|
context.assert(MODULE, '未变化库存返回稳定 token、版本和数量快照', repeatedInventoryResponse,
|
||||||
inventorySnapshotVerified,
|
inventorySnapshotVerified,
|
||||||
`sameToken=${repeatedInventory?.snapshotToken === inventory.snapshotToken}, taskVersion=${repeatedInventory?.taskVersion ?? 'missing'}`, OPTIONS)
|
`sameToken=${repeatedInventory?.snapshotToken === inventory.snapshotToken}, taskVersion=${repeatedInventory?.taskVersion ?? 'missing'}`, OPTIONS)
|
||||||
|
const inventoryBlockSnapshots = (inventory?.inbounds || []).map((entry) => ({
|
||||||
|
inboundId: id(entry?.inboundId),
|
||||||
|
inboundCode: canonicalTask?.items?.find?.((item) => id(item?.inboundId) === id(entry?.inboundId))?.inboundCode || null,
|
||||||
|
resources: (entry?.resources || []).map((resource) => ({
|
||||||
|
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
|
||||||
|
reservationId: id(resource?.reservationId), locationId: id(resource?.locationId),
|
||||||
|
sharedOverflow: resource?.sharedOverflow === true,
|
||||||
|
})),
|
||||||
|
})).filter((entry) => entry.inboundId && entry.resources.length > 0)
|
||||||
|
const inventoryBlockIdentityVerified = inventoryBlockSnapshots.length > 0
|
||||||
|
&& inventoryBlockSnapshots.every((entry) => new Set(entry.resources.map((resource) => resource.blockId)).size === 1)
|
||||||
|
context.assert(MODULE, 'PDA 库存快照按入仓单返回唯一整票库位块身份', inventoryResponse,
|
||||||
|
inventoryBlockIdentityVerified,
|
||||||
|
`inbounds=${inventoryBlockSnapshots.length}, blocks=${inventoryBlockSnapshots.map((entry) => entry.resources[0]?.blockId).join(',')}`, OPTIONS)
|
||||||
|
|
||||||
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
|
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
|
||||||
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
|
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
|
||||||
@@ -747,105 +761,7 @@ export async function run(context) {
|
|||||||
id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId),
|
id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId),
|
||||||
`assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
|
`assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
let inventoryMutationVerified = false
|
const canonicalPick = await request(context, 'POST', batchPath, batchPayload,
|
||||||
let inventoryMutationCleanup = null
|
|
||||||
let effectiveBatchPayload = batchPayload
|
|
||||||
const overflowResource = inventoryInbound?.resources?.find?.((resource) =>
|
|
||||||
resource?.sharedOverflow === true && id(resource?.occupancyId))
|
|
||||||
if (overflowResource) {
|
|
||||||
const mutationSuffix = markerSuffix(context.marker)
|
|
||||||
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) {
|
|
||||||
context.addStep(MODULE, '库存变化fixture创建失败后停止旧snapshot提交', targetLocationResponse,
|
|
||||||
false, responseEvidence(targetLocationResponse), OPTIONS)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (targetLocationId) {
|
|
||||||
inventoryMutationCleanup = {
|
|
||||||
cleaned: false, targetLocationId,
|
|
||||||
reason: '目标库位在真实换绑后属于活动占用,公开删除接口会拒绝,不伪报已清理',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const beforeMutationTaskResponse = await context.request('GET',
|
|
||||||
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&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)
|
|
||||||
const reboundValid = id(rebound?.occupancyId) === id(overflowResource.occupancyId)
|
|
||||||
&& id(rebound?.previousLocationId) === id(overflowResource.locationId)
|
|
||||||
&& id(rebound?.locationId) === targetLocationId
|
|
||||||
context.assert(MODULE, '真实库存换绑保持同仓且位置发生变化', rebindResponse,
|
|
||||||
reboundValid,
|
|
||||||
`occupancyId=${id(rebound?.occupancyId) || 'missing'}, previousLocationId=${id(rebound?.previousLocationId) || 'missing'}, locationId=${id(rebound?.locationId) || 'missing'}`, OPTIONS)
|
|
||||||
if (!reboundValid) {
|
|
||||||
context.addStep(MODULE, '库存换绑未成功后停止旧snapshot提交', rebindResponse,
|
|
||||||
false, responseEvidence(rebindResponse), OPTIONS)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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?loadingPlanId=${encodeURIComponent(loadingPlanId)}&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)
|
|
||||||
context.assert(MODULE, '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', afterStaleTaskResponse,
|
|
||||||
Number(afterStaleTask?.version) === Number(beforeMutationTask?.version)
|
|
||||||
&& taskItemsSignature(afterStaleTask) === taskItemsSignature(beforeMutationTask)
|
|
||||||
&& operationsSignature(afterStaleOperations) === operationsSignature(beforeMutationOperations),
|
|
||||||
`taskVersion=${afterStaleTask?.version ?? 'missing'}, items=${taskItemsSignature(afterStaleTask)}, 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 if (source?.requireInventoryMutation !== false) {
|
|
||||||
context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false,
|
|
||||||
`本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS)
|
|
||||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
|
|
||||||
`inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`,
|
|
||||||
'fixture')
|
|
||||||
} else {
|
|
||||||
context.addStep(MODULE, '非快照专项装柜链不重复制造 A0 库存变化', null, true,
|
|
||||||
'当前模块作为四期真实业务前置,仅验证正常装柜链;旧 snapshot token A0409 由独立 phase3-loading 专项覆盖',
|
|
||||||
{ scenario: OPTIONS.scenario })
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
|
||||||
{ 'Idempotency-Key': batchKey })
|
{ 'Idempotency-Key': batchKey })
|
||||||
const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS)
|
const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS)
|
||||||
const awaitingReview = pickedCanonical?.taskStatus === 'LOADING'
|
const awaitingReview = pickedCanonical?.taskStatus === 'LOADING'
|
||||||
@@ -854,7 +770,7 @@ export async function run(context) {
|
|||||||
awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
|
||||||
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload,
|
const canonicalBatchReplay = await request(context, 'POST', batchPath, batchPayload,
|
||||||
{ 'Idempotency-Key': batchKey })
|
{ 'Idempotency-Key': batchKey })
|
||||||
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
|
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
|
||||||
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId
|
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId
|
||||||
@@ -1179,6 +1095,7 @@ export async function run(context) {
|
|||||||
const incrementalInboundId = id(incrementalFixture?.inboundId)
|
const incrementalInboundId = id(incrementalFixture?.inboundId)
|
||||||
let skipBatchVerified = false
|
let skipBatchVerified = false
|
||||||
let partialBatchVerified = false
|
let partialBatchVerified = false
|
||||||
|
let partialBlockSnapshot = null
|
||||||
let dynamicIncrementVerified = false
|
let dynamicIncrementVerified = false
|
||||||
if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
|
if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
|
||||||
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
|
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
|
||||||
@@ -1277,6 +1194,22 @@ export async function run(context) {
|
|||||||
const partialInventoryResponse = await context.request('GET',
|
const partialInventoryResponse = await context.request('GET',
|
||||||
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
`${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS)
|
const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS)
|
||||||
|
const partialInventoryInbound = partialInventory?.inbounds?.find?.((item) =>
|
||||||
|
id(item?.inboundId) === incrementalInboundId)
|
||||||
|
const partialResources = (partialInventoryInbound?.resources || []).map((resource) => ({
|
||||||
|
blockId: id(resource?.blockId), slotNo: Number(resource?.slotNo),
|
||||||
|
reservationId: id(resource?.reservationId), locationId: id(resource?.locationId),
|
||||||
|
sharedOverflow: resource?.sharedOverflow === true,
|
||||||
|
}))
|
||||||
|
partialBlockSnapshot = {
|
||||||
|
inboundId: incrementalInboundId,
|
||||||
|
inboundCode: unresolvedTaskItem?.inboundCode || null,
|
||||||
|
resources: partialResources,
|
||||||
|
}
|
||||||
|
context.assert(MODULE, 'PARTIAL 前余货属于唯一整票库位块', partialInventoryResponse,
|
||||||
|
partialResources.length > 0
|
||||||
|
&& new Set(partialResources.map((resource) => resource.blockId)).size === 1,
|
||||||
|
`inboundId=${incrementalInboundId}, resources=${partialResources.length}, blockId=${partialResources[0]?.blockId || 'missing'}`, OPTIONS)
|
||||||
const partialBatchResponse = await request(context, 'POST', batchPath, {
|
const partialBatchResponse = await request(context, 'POST', batchPath, {
|
||||||
warehouseId, loadingPlanId, containerBookingId,
|
warehouseId, loadingPlanId, containerBookingId,
|
||||||
expectedVersion: Number(afterUnresolvedCompleteTask?.version),
|
expectedVersion: Number(afterUnresolvedCompleteTask?.version),
|
||||||
@@ -1510,8 +1443,8 @@ export async function run(context) {
|
|||||||
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
|
||||||
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
|
atomicBatchVerified: awaitingReview && atomicBatchReplayVerified,
|
||||||
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
|
inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified,
|
||||||
inventoryMutationVerified, inventoryMutationCleanup,
|
inventoryBlockIdentityVerified, inventoryBlockSnapshots,
|
||||||
inventoryMutationRequired: source?.requireInventoryMutation !== false,
|
partialBlockSnapshot,
|
||||||
skipBatchVerified, partialBatchVerified, dynamicIncrementVerified,
|
skipBatchVerified, partialBatchVerified, dynamicIncrementVerified,
|
||||||
expectedBatchOperationIds, incrementalInboundId,
|
expectedBatchOperationIds, incrementalInboundId,
|
||||||
pdaTaskVersion: Number(stagedTask?.version),
|
pdaTaskVersion: Number(stagedTask?.version),
|
||||||
|
|||||||
@@ -123,17 +123,10 @@ export async function run(context) {
|
|||||||
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
|
'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证',
|
||||||
'deployment-blocker')
|
'deployment-blocker')
|
||||||
}
|
}
|
||||||
if (fixture?.inventoryMutationRequired !== false && fixture?.inventoryMutationVerified !== true
|
if (fixture?.inventoryBlockIdentityVerified !== true
|
||||||
&& !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) {
|
|| !Array.isArray(fixture?.inventoryBlockSnapshots) || fixture.inventoryBlockSnapshots.length === 0) {
|
||||||
context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false,
|
context.addStep(MODULE, 'PDA 整票库位块身份已由正式库存快照验证', null, false,
|
||||||
'本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS)
|
'phase3-loading 未发布唯一 blockId 及 slotNo/reservationId/locationId/sharedOverflow 身份快照', OPTIONS)
|
||||||
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
|
|
||||||
'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化',
|
|
||||||
'fixture')
|
|
||||||
} else if (fixture?.inventoryMutationRequired === false) {
|
|
||||||
context.addStep(MODULE, '依赖链不重复执行 A0 旧快照破坏性专项', null, true,
|
|
||||||
'正常库存快照、原子批次和幂等已验证;A0 换绑与旧 token A0409 仅在显式 phase3-loading 专项运行',
|
|
||||||
{ scenario: OPTIONS.scenario })
|
|
||||||
}
|
}
|
||||||
if (fixture?.dynamicIncrementVerified !== true) {
|
if (fixture?.dynamicIncrementVerified !== true) {
|
||||||
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
|
context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false,
|
||||||
|
|||||||
@@ -240,21 +240,17 @@ test('finalize rechecks the same unified order detail and list as LOADED', () =>
|
|||||||
assert.match(source('./phase3-loading.mjs'), /tenantId, orderId: id\(source\?\.orderId\), inboundId/)
|
assert.match(source('./phase3-loading.mjs'), /tenantId, orderId: id\(source\?\.orderId\), inboundId/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => {
|
test('PDA inventory snapshot gates batch and records whole-ticket block identity', () => {
|
||||||
const loading = source('./phase3-loading.mjs')
|
const loading = source('./phase3-loading.mjs')
|
||||||
assertOrder(loading, [
|
assertOrder(loading, [
|
||||||
'读取 PDA 提交前实时库存快照',
|
'读取 PDA 提交前实时库存快照',
|
||||||
'未发生库存变化时重复读取实时库存快照',
|
'未发生库存变化时重复读取实时库存快照',
|
||||||
|
'PDA 库存快照按入仓单返回唯一整票库位块身份',
|
||||||
'客户身份无权读取平台 PDA 实时库存快照',
|
'客户身份无权读取平台 PDA 实时库存快照',
|
||||||
'const overflowResource =',
|
|
||||||
'通过公开 A0 换绑接口制造真实库存变化',
|
|
||||||
'真实库存变化后旧 snapshot token 提交返回冲突',
|
|
||||||
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
|
|
||||||
'库存变化后刷新 PDA 实时库存快照',
|
|
||||||
'const canonicalPick = await request',
|
'const canonicalPick = await request',
|
||||||
'const canonicalBatchReplay = await request',
|
'const canonicalBatchReplay = await request',
|
||||||
])
|
])
|
||||||
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/)
|
assert.match(loading, /inventorySnapshotToken: inventory\.snapshotToken/)
|
||||||
assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/)
|
assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/)
|
||||||
assert.match(loading,
|
assert.match(loading,
|
||||||
/LATE_CONTAINER_GATE_ONLY \? inventory\?\.currentContainerNo == null : inventory\?\.currentContainerNo === containerNo/)
|
/LATE_CONTAINER_GATE_ONLY \? inventory\?\.currentContainerNo == null : inventory\?\.currentContainerNo === containerNo/)
|
||||||
@@ -262,13 +258,14 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap'
|
|||||||
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
|
assert.doesNotMatch(batchPayload, /containerNo[,\s]/)
|
||||||
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
|
assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)
|
||||||
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified/)
|
assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified/)
|
||||||
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
|
assert.match(loading, /inventoryBlockIdentityVerified, inventoryBlockSnapshots/)
|
||||||
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
|
assert.match(loading, /partialBlockSnapshot/)
|
||||||
|
assert.doesNotMatch(loading, /occupancyId|temporary-occupancies|\/rebind/)
|
||||||
|
|
||||||
const convergence = source('./phase3-pda-convergence.mjs')
|
const convergence = source('./phase3-pda-convergence.mjs')
|
||||||
assert.doesNotMatch(convergence, /未公开实时库存查询或库存版本快照端点/)
|
assert.doesNotMatch(convergence, /未公开实时库存查询或库存版本快照端点/)
|
||||||
assert.match(convergence, /fixture\?\.inventorySnapshotVerified !== true/)
|
assert.match(convergence, /fixture\?\.inventorySnapshotVerified !== true/)
|
||||||
assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/)
|
assert.match(convergence, /fixture\?\.inventoryBlockIdentityVerified !== true/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('container loading arranges a simulation before linking a released booking requirement', () => {
|
test('container loading arranges a simulation before linking a released booking requirement', () => {
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ import test from 'node:test'
|
|||||||
|
|
||||||
const source = readFileSync(new URL('./supplemental-boundaries.mjs', import.meta.url), 'utf8')
|
const source = readFileSync(new URL('./supplemental-boundaries.mjs', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('occupied location deletion protection reuses only the marker-owned loading fixture', () => {
|
test('supplemental boundaries do not mutate PDA block locations through retired occupancy APIs', () => {
|
||||||
assert.match(source, /phase3Loading/)
|
assert.doesNotMatch(source, /inventoryMutationCleanup|temporary-occupancies|occupancyId|\/rebind/)
|
||||||
assert.match(source, /inventoryMutationCleanup\?\.targetLocationId/)
|
|
||||||
assert.match(source, /loading\?\.marker === context\.marker/)
|
|
||||||
assert.match(source, /真实占用库位删除返回A0409且保留资源/)
|
|
||||||
assert.match(source, /占用删除冲突未造成资源丢失/)
|
|
||||||
assert.doesNotMatch(source, /no-public-fixture-api|当前公共验收 API 未提供/)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,31 +86,6 @@ export async function run(context) {
|
|||||||
locationVersion = Number(afterData?.location?.version)
|
locationVersion = Number(afterData?.location?.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// phase3-loading has already created and rebound a marker-owned live
|
|
||||||
// occupancy. Reuse that exact location to prove deletion protection without
|
|
||||||
// touching historical resources or inventing a setup-only API.
|
|
||||||
const loading = context.createdResources?.phase3Loading
|
|
||||||
const occupiedLocationId = id(loading?.inventoryMutationCleanup?.targetLocationId)
|
|
||||||
const occupiedWarehouseId = id(loading?.warehouseId)
|
|
||||||
context.assert(MODULE, '取得本轮装柜链真实占用库位', null,
|
|
||||||
Boolean(occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker),
|
|
||||||
`locationId=${occupiedLocationId || 'missing'}, warehouseId=${occupiedWarehouseId || 'missing'}`, OPTIONS)
|
|
||||||
if (occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker) {
|
|
||||||
const occupiedPath = `/api/platform/v1/locations/${occupiedLocationId}?tenantId=0&warehouseId=${occupiedWarehouseId}`
|
|
||||||
const occupiedDetail = await context.request('GET', occupiedPath)
|
|
||||||
const occupied = context.apiSuccess(MODULE, '读取本轮真实占用库位版本', occupiedDetail, '', OPTIONS)
|
|
||||||
const occupiedVersion = Number(occupied?.location?.version)
|
|
||||||
const rejectedDelete = await context.request('DELETE', `${occupiedPath}&version=${occupiedVersion}`)
|
|
||||||
context.addStep(MODULE, '真实占用库位删除返回A0409且保留资源', rejectedDelete,
|
|
||||||
rejectedDelete.http === 200 && ['A0409', '409'].includes(codeOf(rejectedDelete)),
|
|
||||||
`HTTP=${rejectedDelete.http}, code=${codeOf(rejectedDelete)}, version=${occupiedVersion}`, OPTIONS)
|
|
||||||
const occupiedAfter = await context.request('GET', occupiedPath)
|
|
||||||
const after = context.apiSuccess(MODULE, '删除冲突后回查占用库位', occupiedAfter, '', OPTIONS)
|
|
||||||
context.assert(MODULE, '占用删除冲突未造成资源丢失', occupiedAfter,
|
|
||||||
id(after?.location?.id) === occupiedLocationId,
|
|
||||||
`expected=${occupiedLocationId}, actual=${id(after?.location?.id) || 'missing'}`, OPTIONS)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import boundary probes use only in-memory bytes and dry-run where supported.
|
// Import boundary probes use only in-memory bytes and dry-run where supported.
|
||||||
const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00])
|
const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00])
|
||||||
const empty = new Uint8Array()
|
const empty = new Uint8Array()
|
||||||
|
|||||||
Reference in New Issue
Block a user