diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index ed57a829b..7e3535425 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -8,6 +8,7 @@ const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: t const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' +const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } export function markerSuffix(marker) { @@ -496,19 +497,24 @@ export async function run(context) { const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`) const latestContainer = context.apiSuccess(MODULE, '合并前读取模拟柜最新版本', latestContainerResponse, '', OPTIONS) const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, { - oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, + oceanBookingId: bookingId, requirementId: id(requirement.id), + ...(LATE_CONTAINER_GATE_ONLY ? {} : { containerNo }), expectedVersion: Number(latestContainer?.version), }) - const linked = context.apiSuccess(MODULE, '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS) - context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse, + const linked = context.apiSuccess(MODULE, LATE_CONTAINER_GATE_ONLY + ? '无号模拟柜原地关联三期 RELEASED 订舱需求' + : '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS) + context.assert(MODULE, '订舱关联保持柜次计划与运输身份', linkResponse, id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo - && linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId, + && (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo) + && id(linked?.oceanBookingId) === bookingId, `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) if (id(linked?.id) !== containerBookingId) return context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse, bookingConfirmed?.oceanBooking?.status === 'RELEASED' - && id(linked?.oceanBookingId) === bookingId && linked?.containerNo === containerNo, + && id(linked?.oceanBookingId) === bookingId + && (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo), `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS) const unconfirmedTaskResponse = await context.request('GET', @@ -610,7 +616,8 @@ export async function run(context) { && id(inventory?.loadingPlanId) === loadingPlanId && id(inventory?.containerBookingId) === containerBookingId && Number(inventory?.taskVersion) === Number(claimed?.version) - && id(inventory?.cabinetNo) && inventory?.currentContainerNo === containerNo + && id(inventory?.cabinetNo) + && (LATE_CONTAINER_GATE_ONLY ? inventory?.currentContainerNo == null : inventory?.currentContainerNo === containerNo) && id(inventory?.warehouseId) === warehouseId && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 @@ -869,6 +876,70 @@ export async function run(context) { && id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING', `status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS) + if (LATE_CONTAINER_GATE_ONLY) { + const missingOperationId = `${context.marker}-late-container-missing-complete`.slice(0, 128) + const missingComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(pickedCanonical?.version), operationId: missingOperationId, + remark: `${context.marker}-missing-container`, + }) + context.addStep(MODULE, '无号拣货完成后 PDA complete 精确返回 A0409', missingComplete, + missingComplete.http === 200 && codeOf(missingComplete) === 'A0409' + && String(missingComplete.body?.message || '').includes('真实集装箱号'), + `${responseEvidence(missingComplete)}, message=${missingComplete.body?.message || 'missing'}`, OPTIONS) + + const transport = await request(context, 'PATCH', + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { + containerNo, voyageNo: bookingDraft.voyageNo, expectedVersion: Number(pickedPlan?.version), + operationReason: null, + }, { 'Idempotency-Key': `${context.marker}-late-container-fill`.slice(0, 128) }) + const filled = context.apiSuccess(MODULE, 'LOADING 阶段通过预装运输信息首次补录真实柜号', transport, + responseEvidence(transport), OPTIONS) + context.assert(MODULE, '首次补号保持计划与柜次身份且柜号规范化落盘', transport, + id(filled?.summary?.planId) === loadingPlanId + && id(filled?.summary?.containerBookingId) === containerBookingId + && filled?.summary?.status === 'LOADING' && filled?.summary?.containerNo === containerNo, + `planId=${id(filled?.summary?.planId) || 'missing'}, containerBookingId=${id(filled?.summary?.containerBookingId) || 'missing'}, containerNo=${filled?.summary?.containerNo || 'missing'}`, OPTIONS) + + const afterFillTaskResponse = await context.request('GET', taskDetailPath) + const afterFillTask = context.apiSuccess(MODULE, '首次补号后回读原 PDA 任务', afterFillTaskResponse, '', OPTIONS) + context.assert(MODULE, '补号不替换 task/plan/cabinet 稳定身份', afterFillTaskResponse, + id(afterFillTask?.id) === canonicalTaskId && id(afterFillTask?.loadingPlanId) === loadingPlanId + && id(afterFillTask?.containerBookingId) === containerBookingId + && afterFillTask?.currentContainerNo === containerNo, + `taskId=${id(afterFillTask?.id) || 'missing'}, currentContainerNo=${afterFillTask?.currentContainerNo || 'missing'}`, OPTIONS) + + const acceptedOperationId = `${context.marker}-late-container-accepted-complete`.slice(0, 128) + const acceptedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(afterFillTask?.version), operationId: acceptedOperationId, + remark: `${context.marker}-container-filled`, + }) + context.assert(MODULE, '补号后 complete 已越过柜号门禁并受理异步命令', acceptedComplete, + acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200' + && ['PROCESSING', 'FAILED', 'COMPLETED'].includes(acceptedComplete.body?.data?.workflowStatus), + `${responseEvidence(acceptedComplete)}, workflow=${acceptedComplete.body?.data?.workflowStatus || 'missing'}`, OPTIONS) + + let terminalTask = acceptedComplete.body?.data + let terminalResponse = acceptedComplete + for (let attempt = 0; attempt < 10 && terminalTask?.workflowStatus === 'PROCESSING'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) + terminalResponse = await context.request('GET', taskDetailPath) + if (terminalResponse.http === 200 && codeOf(terminalResponse) === '200') terminalTask = terminalResponse.body?.data + } + context.assert(MODULE, '补号后的下一完成门禁返回明确终态而非柜号冲突', terminalResponse, + terminalTask?.workflowStatus !== 'PROCESSING' + && !String(terminalTask?.completionCommand?.failureMessage || '').includes('集装箱号'), + `workflow=${terminalTask?.workflowStatus || 'missing'}, stage=${terminalTask?.completionCommand?.failureStage || 'none'}, code=${terminalTask?.completionCommand?.failureCode || 'none'}, message=${terminalTask?.completionCommand?.failureMessage || 'none'}`, OPTIONS) + context.createdResources.phase3LateContainerGate = { + marker: context.marker, tenantId, inboundId, containerBookingId, cabinetNo: simulation.cabinetNo, + oceanBookingId: bookingId, loadingPlanId, taskId: canonicalTaskId, containerNo, + missingComplete: responseEvidence(missingComplete), fill: responseEvidence(transport), + acceptedComplete: responseEvidence(acceptedComplete), finalWorkflow: terminalTask?.workflowStatus || null, + nextFailureStage: terminalTask?.completionCommand?.failureStage || null, + nextFailureCode: terminalTask?.completionCommand?.failureCode || null, + } + return + } + const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS) context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload,