diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index a91efae16..0d31e9a64 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -374,7 +374,7 @@ export async function run(context) { const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${containerId}/transport`, { containerNo: `TST-${suffix}`.slice(0, 64), voyageNo: `V-${suffix}`.slice(0, 64), - version: Number(containerDetail?.version ?? 0), reason: context.marker, + version: Number(linkedContainer.version), reason: context.marker, }) const transported = context.apiSuccess(MODULE, '在放舱前绑定本轮真实箱号', transportResponse, '', OPTIONS) context.assert(MODULE, '绑定后柜次保留本轮箱号', transportResponse, diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 096eb9640..fd3e3d640 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -75,6 +75,7 @@ test('existing container number replacement keeps container plan and PDA identit test('PDA task opens only after preload push, real container number and ocean release converge', () => { assert.match(containerLoadingAcceptance, /无真实柜号时计划可排柜但 PDA 任务不开放/) + assert.match(containerLoadingAcceptance, /version: Number\(linkedContainer\.version\), reason: context\.marker/) assert.match(containerLoadingAcceptance, /订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务/) assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/) assert.match(containerLoadingAcceptance, /订舱放舱并开放现场 PDA 任务/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index ed73c5a82..a73403919 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -170,6 +170,7 @@ export async function run(context) { id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo && id(linked?.oceanBookingId) === bookingId, `containerBookingId=${containerBookingId}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS) + if (id(linked?.id) !== containerBookingId) return const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS) @@ -233,29 +234,55 @@ export async function run(context) { `linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) if (!link?.id) return + const missingContainerPushResponse = await request(context, 'POST', + `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, + { expectedVersion: Number(link.loadingPlanVersion) }, + { 'Idempotency-Key': `${context.marker}-bridge-push-without-container-no`.slice(0, 128) }) + const missingContainerPushRejected = failure(missingContainerPushResponse) + context.addStep(MODULE, '无真实柜号拒绝推送柜次计划', missingContainerPushResponse, + missingContainerPushRejected, + `HTTP=${missingContainerPushResponse.http}, code=${codeOf(missingContainerPushResponse)}, requestId=${missingContainerPushResponse.requestId || 'missing'}, traceId=${missingContainerPushResponse.traceId || 'missing'}`, OPTIONS) + if (!missingContainerPushRejected) return + + const missingContainerTaskResponse = await context.request('GET', + `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const missingContainerTaskAbsent = failure(missingContainerTaskResponse) + || !id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id) + context.addStep(MODULE, '无真实柜号推送失败后不生成 PDA 任务', missingContainerTaskResponse, + missingContainerTaskAbsent, + `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, task=${id(missingContainerTaskResponse?.body?.id || missingContainerTaskResponse?.body?.data?.id) || 'none'}`, OPTIONS) + if (!missingContainerTaskAbsent) return + + const containerBeforeTransportResponse = await context.request('GET', + `/api/platform/v1/containers/${containerBookingId}`) + const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次最新版本', + containerBeforeTransportResponse, '', OPTIONS) + const transportVersion = Number(containerBeforeTransport?.version) + context.assert(MODULE, '补真实柜号使用订舱关联后的最新柜次版本', containerBeforeTransportResponse, + id(containerBeforeTransport?.id) === containerBookingId && Number.isInteger(transportVersion), + `containerBookingId=${id(containerBeforeTransport?.id) || 'missing'}, version=${containerBeforeTransport?.version ?? 'missing'}`, OPTIONS) + if (!Number.isInteger(transportVersion)) return + const transportResponse = await request(context, 'PATCH', + `/api/platform/v1/containers/${containerBookingId}/transport`, { + containerNo, voyageNo: `P3V-${suffix}`, version: transportVersion, + reason: `${context.marker}-bind-container-no`, + }) + const transported = context.apiSuccess(MODULE, '绑定真实柜号后开放三期 PDA 推送门禁', transportResponse, '', OPTIONS) + context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse, + transported?.containerNo === containerNo && id(transported?.id) === containerBookingId + && transported?.cabinetNo === linked.cabinetNo, + `containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}, loadingPlanId=${loadingPlanId}`, OPTIONS) + if (transported?.containerNo !== containerNo) return + const pushResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, { expectedVersion: Number(link.loadingPlanVersion) }, { 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }) - const pushedLoadingPlan = context.apiSuccess(MODULE, '桥接后推送柜次计划并生成真实 PDA 任务', pushResponse, '', OPTIONS) + const pushedLoadingPlan = context.apiSuccess(MODULE, '绑定真实柜号后推送柜次计划并生成 PDA 任务', pushResponse, '', OPTIONS) context.assert(MODULE, '推送后柜次计划保持 EDITING 且版本递增', pushResponse, pushedLoadingPlan?.status === 'EDITING' && Number(pushedLoadingPlan?.version) === Number(link.loadingPlanVersion) + 1, `status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS) - const containerBeforeTransportResponse = await context.request('GET', - `/api/platform/v1/containers/${containerBookingId}`) - const containerBeforeTransport = context.apiSuccess(MODULE, '读取补真实柜号前柜次版本', - containerBeforeTransportResponse, '', OPTIONS) - const transportResponse = await request(context, 'PATCH', - `/api/platform/v1/containers/${containerBookingId}/transport`, { - containerNo, voyageNo: `P3V-${suffix}`, version: Number(containerBeforeTransport?.version), - reason: `${context.marker}-bind-container-no`, - }) - const transported = context.apiSuccess(MODULE, '补真实柜号并开放三期 PDA 任务', transportResponse, '', OPTIONS) - context.assert(MODULE, '真实柜号绑定到稳定柜次且计划身份不变', transportResponse, - transported?.containerNo === containerNo && id(transported?.id) === containerBookingId, - `containerNo=${transported?.containerNo || 'missing'}, containerBookingId=${id(transported?.id) || 'missing'}`, OPTIONS) - const blockedBeforePreloadConfirm = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: initialVersion, remark: 'must confirm preload first' }, { 'Idempotency-Key': `${context.marker}-p3-complete-before-preload-confirm`.slice(0, 128) }) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 5570e4d66..aa2b893a7 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -86,6 +86,11 @@ test('container loading follows push, claim, real pick, completed sequence', () test('bridge loading starts through a real PDA batch before preload confirmation', () => { const value = source('./phase3-loading.mjs') assertOrder(value, [ + '原子关联入库预装与柜次装柜计划', + '无真实柜号拒绝推送柜次计划', + '无真实柜号推送失败后不生成 PDA 任务', + '绑定真实柜号后开放三期 PDA 推送门禁', + '绑定真实柜号后推送柜次计划并生成 PDA 任务', "pushedLoadingPlan?.status === 'EDITING'", "claimedPlan?.status === 'LOADING'", "const canonicalPick = await request", @@ -98,6 +103,10 @@ test('bridge loading starts through a real PDA batch before preload confirmation assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/) assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/) assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === 2/) + assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/) + assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/) + assert.match(value, /version: transportVersion/) + assert.match(value, /Number\.isInteger\(transportVersion\)/) }) test('PDA inventory snapshot gates batch and records the only safe mutation gap', () => { @@ -149,6 +158,8 @@ test('container loading arranges a simulation before linking a confirmed real bo const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\'')) assert.doesNotMatch(draft, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/) assert.match(value, /containerAllocations\.length === 0/) + assert.match(value, /version: Number\(linkedContainer\.version\), reason: context\.marker/) + assert.doesNotMatch(value, /version: Number\(containerDetail\?\.version \?\? 0\), reason: context\.marker/) assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/) assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/) assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 5d94c88c5..766665232 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -61,3 +61,23 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/) assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/) }) + +test('test-environment loading chains bind the latest container version before opening PDA', () => { + const containerLoading = readFileSync( + resolve(scriptsDir, 'admin-test-env-modules', 'phase3-container-loading.mjs'), 'utf8') + const bridgeLoading = readFileSync( + resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') + + assert.match(containerLoading, /version: Number\(linkedContainer\.version\), reason: context\.marker/) + + const bridgeIndex = bridgeLoading.indexOf('原子关联入库预装与柜次装柜计划') + const rejectedPushIndex = bridgeLoading.indexOf('无真实柜号拒绝推送柜次计划') + const transportIndex = bridgeLoading.indexOf('绑定真实柜号后开放三期 PDA 推送门禁') + const successfulPushIndex = bridgeLoading.indexOf('绑定真实柜号后推送柜次计划并生成 PDA 任务') + const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务') + assert.ok(bridgeIndex >= 0 && rejectedPushIndex > bridgeIndex && transportIndex > rejectedPushIndex + && successfulPushIndex > transportIndex && pdaIndex > successfulPushIndex) + assert.match(bridgeLoading, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/) + assert.match(bridgeLoading, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/) + assert.match(bridgeLoading, /version: transportVersion/) +})