diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index e95ac48ce..05f1e7558 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -287,6 +287,7 @@ function usage() { --wait-split-package-count 单票指定总箱数,1-1000;省略时使用既有轮换 --wait-split-sequence 单票全局序号,正整数,默认1 --wait-split-random-value 单票随机选择值,正整数,默认使用全局序号 + --verify-pallet-location-guide 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY --route-mode 线路选择:fixed 或 random,默认fixed --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 --manual-split-plan-id 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 @@ -347,6 +348,7 @@ export function parseArguments(argv) { waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), waitSplitSequence: Number(values.get('wait-split-sequence') || 1), waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1), + verifyPalletLocationGuide: values.has('verify-pallet-location-guide'), routeMode: values.get('route-mode') || 'fixed', quiet: values.get('quiet') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index db9ca72ab..a4eaaf931 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -176,7 +176,10 @@ async function createConfirmedWeightEvidence(context, { staleConfirmResponse.http === 200 && String(staleConfirmResponse.body?.code) === 'A0409', `HTTP=${staleConfirmResponse.http}, code=${staleConfirmResponse.body?.code ?? 'missing'}, requestId=${staleConfirmResponse.requestId || 'missing'}`, OPTIONS) - confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg }) + confirmedResults.push({ + id: asId(created.id), palletId, netWeightKg, grossWeightKg, + version: Number(confirmed?.version), + }) } const workspaceResponse = await context.request('GET', @@ -197,7 +200,41 @@ async function createConfirmedWeightEvidence(context, { context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, OPTIONS) - return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null + return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg, confirmedResults } : null +} + +function verifyPalletLocationGuide(context, response, workspace, expectedWeightResultIds, name) { + const guide = workspace?.palletLocationGuide + const slots = Array.isArray(guide?.slots) ? guide.slots : [] + const weights = Array.isArray(workspace?.weightResults) ? workspace.weightResults : [] + const boundSlots = slots.filter((slot) => asId(slot?.boundWeightResultId)) + const emptySlots = slots.filter((slot) => slot?.slotStatus === 'RESERVED_EMPTY') + const identityComplete = asId(guide?.blockId) + && slots.length === Number(guide?.reservedSlotCount) + && Number(guide?.actualPalletCount) === boundSlots.length + && Number(guide?.reservedEmptySlotCount) === emptySlots.length + && slots.every((slot) => Number.isInteger(Number(slot?.slotNo)) && Number(slot.slotNo) > 0 + && asId(slot?.reservationId) && asId(slot?.locationId) + && String(slot?.locationCode || '').trim() && typeof slot?.sharedOverflow === 'boolean' + && ['RESERVED_EMPTY', 'BOUND', 'TRANSFERRED', 'RELEASED'].includes(String(slot?.slotStatus || ''))) + context.assert(MODULE, `${name}整票库位块计数与槽身份完整`, response, Boolean(identityComplete), + `blockId=${asId(guide?.blockId) || 'missing'}, reserved=${guide?.reservedSlotCount ?? 'missing'}, actual=${guide?.actualPalletCount ?? 'missing'}, empty=${guide?.reservedEmptySlotCount ?? 'missing'}, slots=${slots.length}`, + OPTIONS) + + const bindingsStable = expectedWeightResultIds.every((resultId) => { + const row = weights.find((item) => asId(item?.id) === resultId) + const slot = slots.find((item) => asId(item?.boundWeightResultId) === resultId) + return row && slot && asId(row.blockId) === asId(guide?.blockId) + && Number(row.slotNo) === Number(slot.slotNo) + && asId(row.reservationId) === asId(slot.reservationId) + && asId(row.locationId) === asId(slot.locationId) + && String(row.locationCode || '') === String(slot.locationCode || '') + && Boolean(row.sharedOverflow) === Boolean(slot.sharedOverflow) + }) + context.assert(MODULE, `${name}称重图片按 resultId 显式绑定槽位`, response, bindingsStable, + `expectedResultIds=${expectedWeightResultIds.join('|') || 'none'}, boundResultIds=${boundSlots.map((slot) => asId(slot.boundWeightResultId)).join('|') || 'none'}`, + OPTIONS) + return { guide, slots, weights, identityComplete: Boolean(identityComplete), bindingsStable } } function findIsolatedInbound(context) { @@ -613,6 +650,62 @@ export async function run(context) { submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING', `processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS) + if (context.options?.verifyPalletLocationGuide) { + const guideResponse = await context.request('GET', measurementPath) + const guideWorkspace = context.apiSuccess(MODULE, '提交后读取整票库位块与称重图片槽绑定', guideResponse, + `inboundId=${inboundId}`, OPTIONS) + const beforeDelete = verifyPalletLocationGuide(context, guideResponse, guideWorkspace, + weightEvidence.confirmedResults.map((item) => item.id), '删除前') + const overflowSlots = beforeDelete.slots.filter((slot) => slot?.sharedOverflow === true) + context.addStep(MODULE, '正式数据 A0 共享溢出观测', guideResponse, true, + overflowSlots.length > 0 + ? `sharedOverflow=true, slots=${overflowSlots.map((slot) => `${slot.slotNo}:${slot.reservationId}:${slot.locationCode}`).join('|')}` + : '本票未触发 A0;为保护共享测试仓,不批量停用普通库位制造容量不足', + { scenario: SCENARIO }) + + const deletedWeight = weightEvidence.confirmedResults[0] + const deleteResponse = await context.request('DELETE', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${deletedWeight.id}?${query(tenantId)}&version=${deletedWeight.version}`) + const deleteSucceeded = deleteResponse.http === 200 && String(deleteResponse.body?.code) === '200' + context.assert(MODULE, '删除称重图片后保留原 reservation/location 并释放为空槽', deleteResponse, + deleteSucceeded, + `HTTP=${deleteResponse.http}, code=${deleteResponse.body?.code ?? 'missing'}, message=${deleteResponse.body?.message ?? 'missing'}, requestId=${deleteResponse.requestId || 'missing'}, traceId=${deleteResponse.traceId || 'missing'}`, + OPTIONS) + if (deleteSucceeded) { + const afterResponse = await context.request('GET', measurementPath) + const afterWorkspace = context.apiSuccess(MODULE, '删除后回查整票库位块', afterResponse, + `deletedWeightResultId=${deletedWeight.id}`, OPTIONS) + const afterDelete = verifyPalletLocationGuide(context, afterResponse, afterWorkspace, + weightEvidence.confirmedResults.slice(1).map((item) => item.id), '删除后') + const previousSlot = beforeDelete.slots.find((slot) => asId(slot?.boundWeightResultId) === deletedWeight.id) + const retainedSlot = afterDelete.slots.find((slot) => + asId(slot?.reservationId) === asId(previousSlot?.reservationId) + && asId(slot?.locationId) === asId(previousSlot?.locationId)) + context.assert(MODULE, '删除只解绑图片且原槽变为 RESERVED_EMPTY', afterResponse, + Boolean(previousSlot) && retainedSlot?.slotStatus === 'RESERVED_EMPTY' + && !asId(retainedSlot?.boundWeightResultId) + && Number(afterDelete.guide?.reservedSlotCount) === Number(beforeDelete.guide?.reservedSlotCount), + `reservationId=${asId(retainedSlot?.reservationId) || 'missing'}, locationId=${asId(retainedSlot?.locationId) || 'missing'}, slotStatus=${retainedSlot?.slotStatus || 'missing'}`, + OPTIONS) + context.createdResources.inboundMeasurementLocationGuide = { + marker: context.marker, inboundId, tenantId, blockId: asId(afterDelete.guide?.blockId), + deletedWeightResultId: deletedWeight.id, deleteSucceeded: true, + sharedOverflowObserved: afterDelete.slots.some((slot) => slot?.sharedOverflow === true), + } + finishScenario(context, start) + return + } + context.createdResources.inboundMeasurementLocationGuide = { + marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId), + deletedWeightResultId: deletedWeight.id, deleteSucceeded: false, + deleteCode: deleteResponse.body?.code || null, + deleteMessage: deleteResponse.body?.message || null, + deleteRequestId: deleteResponse.requestId || null, + deleteTraceId: deleteResponse.traceId || null, + sharedOverflowObserved: overflowSlots.length > 0, + } + } + const rejectedScanResponse = await multipartRequest(context, scanPath, { boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19), })