diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 28c62b923..2feac4e9e 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -21,6 +21,19 @@ test('retains all clickable container, loading-list and PDA workflow states thro const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`') const transportIndex = source.indexOf('`${CONTAINERS}/${containerId}/transport`') assert.ok(transportIndex >= 0 && pushIndex > transportIndex) + const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex) + const confirmIndex = source.indexOf('`${LOADING}/loading-plans/${planId}/confirm`', unconfirmedTaskIndex) + const canonicalTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', unconfirmedTaskIndex + 1) + assert.ok(unconfirmedTaskIndex > pushIndex && confirmIndex > unconfirmedTaskIndex + && canonicalTaskIndex > confirmIndex) + assert.match(source, /计划确认前不生成 PDA 任务/) + assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /confirmationStatus === 'CONFIRMED'/) + assert.match(source, /readiness\?\.readyForPda === true/) + assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/) + assert.match(source, /计划确认同键同参精确重放/) + assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.match(source, /transported\?\.cabinetNo !== container\?\.cabinetNo/) assert.match(source, /shippingCompanyId: null, shippingCompanyName: null/) assert.match(source, /bookingAgentId: null, bookingAgentName: null/) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 64d0ab42c..3f99ed65b 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -185,10 +185,57 @@ async function createPlanTask(context, label) { const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, { expectedVersion: Number(plan.version) }, `${label}-push`) plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS) + const planId = id(plan?.id) + const unconfirmedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`) + context.addStep(MODULE, `${label} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse, + unconfirmedTaskResponse.http === 200 && String(unconfirmedTaskResponse?.body?.code) === 'A0404', + `HTTP=${unconfirmedTaskResponse.http}, code=${unconfirmedTaskResponse?.body?.code ?? 'missing'}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS) + + const confirmableResponse = await context.request('GET', + `${LOADING}/loading-plans/${planId}?warehouseId=${source.warehouseId}`) + const confirmable = context.apiSuccess(MODULE, `${label} 读取计划级确认即时版本`, confirmableResponse, '', OPTIONS) + const confirmableVersion = Number(confirmable?.summary?.version) + context.assert(MODULE, `${label} 整计划允许确认且 PDA 尚未就绪`, confirmableResponse, + Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING' + && confirmable?.summary?.readiness?.canConfirm === true + && confirmable?.summary?.readiness?.readyForPda === false + && confirmable?.summary?.readiness?.pdaTaskId == null, + `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) + if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) { + throw new Error(`${label} 计划尚未满足整计划确认门禁`) + } + + const confirmPath = `${LOADING}/loading-plans/${planId}/confirm` + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmHeaders = Object.freeze({ + 'Idempotency-Key': operationId(context, `${label}-plan-confirm`), + }) + const confirmResponse = await context.request('POST', confirmPath, confirmBody, context.token, confirmHeaders) + const confirmed = context.apiSuccess(MODULE, `${label} 计划级确认并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS) + const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId) + context.assert(MODULE, `${label} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse, + id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId + && confirmed?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId) + && Number(confirmed?.version) > confirmableVersion, + `confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS) + if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') { + throw new Error(`${label} 计划确认未生成唯一 PDA 任务`) + } + + const confirmReplay = await context.request('POST', confirmPath, confirmBody, context.token, confirmHeaders) + context.assert(MODULE, `${label} 计划确认同键同参精确重放`, confirmReplay, + confirmReplay.http === 200 && String(confirmReplay?.body?.code) === '200' + && id(confirmReplay?.body?.data?.readiness?.pdaTaskId) === confirmedTaskId + && Number(confirmReplay?.body?.data?.version) === Number(confirmed.version), + `HTTP=${confirmReplay.http}, code=${confirmReplay?.body?.code ?? 'missing'}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS) + const taskResponse = await context.request('GET', - `${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`) + `${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`) const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS) - if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`) + if (id(task?.id) !== confirmedTaskId || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成或身份漂移`) return { source, containerId, containerNo, plan, task } } diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs new file mode 100644 index 000000000..d97113dd8 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge-contract.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { stableLinkSnapshot } from './phase3-loading-model-bridge.mjs' + +const source = readFileSync(fileURLToPath(new URL('./phase3-loading-model-bridge.mjs', import.meta.url)), 'utf8') + +test('bridge acceptance treats an existing pair as a read-only idempotent replay', () => { + assert.match(source, /已由推送建立的同一桥接 pair 重放返回原关联/) + assert.match(source, /桥接 pair 重放不改关联身份、状态或双侧版本/) + assert.doesNotMatch(source, /桥接同幂等键不同版本请求拒绝且不产生部分更新/) +}) + +test('bridge replay comparison freezes stable identity status and both versions', () => { + const link = { + id: '1', tenantId: '10', inboundId: '20', preloadPlanId: '30', preloadStatus: 'PENDING', + preloadVersion: 3, loadingPlanId: '40', containerBookingId: '50', containerNo: null, + loadingPlanStatus: 'EDITING', loadingPlanVersion: 4, allocatedPackageCount: 8, + linkedAt: '2026-08-15T09:00:00', ignoredTransportField: 'not-part-of-the-link-contract', + } + assert.deepEqual(stableLinkSnapshot({ ...link, ignoredTransportField: 'changed' }), stableLinkSnapshot(link)) + assert.notDeepEqual(stableLinkSnapshot({ ...link, loadingPlanVersion: 5 }), stableLinkSnapshot(link)) + assert.notDeepEqual(stableLinkSnapshot({ ...link, preloadVersion: 4 }), stableLinkSnapshot(link)) + assert.notDeepEqual(stableLinkSnapshot({ ...link, allocatedPackageCount: 7 }), stableLinkSnapshot(link)) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs index 8592e8235..a749c8b50 100644 --- a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs @@ -2,7 +2,19 @@ const MODULE = 'phase3-loading-model-bridge' const OPTIONS = { scenario: 'parallel-loading-models', deploymentBlocker: true } function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } -function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') } +export function stableLinkSnapshot(value) { + return { + id: id(value?.id), tenantId: id(value?.tenantId), inboundId: id(value?.inboundId), + preloadPlanId: id(value?.preloadPlanId), preloadStatus: id(value?.preloadStatus), + preloadVersion: Number(value?.preloadVersion), loadingPlanId: id(value?.loadingPlanId), + containerBookingId: id(value?.containerBookingId), containerNo: id(value?.containerNo), + loadingPlanStatus: id(value?.loadingPlanStatus), loadingPlanVersion: Number(value?.loadingPlanVersion), + allocatedPackageCount: Number(value?.allocatedPackageCount), linkedAt: id(value?.linkedAt), + } +} +function sameStableLink(left, right) { + return JSON.stringify(stableLinkSnapshot(left)) === JSON.stringify(stableLinkSnapshot(right)) +} async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, @@ -37,10 +49,25 @@ export async function run(context) { context.assert(MODULE, '柜次侧反查到同一预装计划', planResponse, Array.isArray(planLinks) && planLinks.some((item) => id(item.inboundId) === inboundId && id(item.preloadPlanId) === id(fixture.preloadPlanId)), `links=${planLinks?.length ?? 'n/a'}`, OPTIONS) - const forged = await request(context, 'POST', + const pairReplay = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, { expectedPreloadVersion: 0, expectedLoadingPlanVersion: 0 }, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) }) - context.addStep(MODULE, '桥接同幂等键不同版本请求拒绝且不产生部分更新', forged, failure(forged), - `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) + const replayedLink = pairReplay?.body?.data + context.addStep(MODULE, '已由推送建立的同一桥接 pair 重放返回原关联', pairReplay, + pairReplay.http === 200 && codeOf(pairReplay) === '200' && sameStableLink(replayedLink, link), + `HTTP=${pairReplay.http}, code=${codeOf(pairReplay)}, linkId=${id(replayedLink?.id) || 'missing'}`, OPTIONS) + + const afterInboundResponse = await request(context, 'GET', inboundPath) + const afterInboundLinks = context.apiSuccess(MODULE, '桥接 pair 重放后回查入库侧关联', afterInboundResponse, '', OPTIONS) + const afterInboundLink = Array.isArray(afterInboundLinks) + ? afterInboundLinks.find((item) => id(item.loadingPlanId) === loadingPlanId) : null + const afterPlanResponse = await request(context, 'GET', `/api/platform/v1/first-leg/smart-loading/plans/${loadingPlanId}/preloads`) + const afterPlanLinks = context.apiSuccess(MODULE, '桥接 pair 重放后回查柜次侧关联', afterPlanResponse, '', OPTIONS) + const afterPlanLink = Array.isArray(afterPlanLinks) + ? afterPlanLinks.find((item) => id(item.inboundId) === inboundId) : null + context.assert(MODULE, '桥接 pair 重放不改关联身份、状态或双侧版本', afterPlanResponse, + sameStableLink(afterInboundLink, link) && sameStableLink(afterPlanLink, link), + `linkId=${id(link?.id)}, preloadVersion=${link?.preloadVersion}, loadingPlanVersion=${link?.loadingPlanVersion}`, + OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 19eaf39f2..c7285ba93 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -43,12 +43,23 @@ test('PDA opens only after a real container number and uses stable plan identity const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`") const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`") const releaseIndex = source.indexOf("`${bookingPath}/release`") + const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`") const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`") - assert.ok(transportIndex >= 0 && releaseIndex > transportIndex && pushIndex > releaseIndex && taskIndex > pushIndex) + const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1) + assert.ok(transportIndex >= 0 && releaseIndex > transportIndex && pushIndex > releaseIndex + && taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex) assert.match(source, /containerNo: realContainerNo/) assert.match(source, /const transportStable = transported\?\.containerNo === realContainerNo/) assert.match(source, /transported\?\.cabinetNo === simulation\.cabinetNo/) assert.match(source, /if \(!transportStable\) return null/) + assert.match(source, /计划确认前不生成 PDA 任务/) + assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) + assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) + assert.match(source, /confirmationStatus === 'CONFIRMED'/) + assert.match(source, /readiness\?\.readyForPda === true/) + assert.match(source, /taskId === confirmedTaskId/) + assert.match(source, /计划确认同键同参精确重放/) + assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.match(source, /id\(task\?\.loadingPlanId\) === planId/) assert.match(source, /id\(task\?\.containerBookingId\) === containerId/) assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index abce7ab73..b51c21344 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -276,13 +276,53 @@ async function loadOneFragment(context, state) { pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS) if (pushed?.status !== 'EDITING') return null + const unconfirmedTaskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + context.addStep(MODULE, `${sourceOrderNo} 计划确认前不生成 PDA 任务`, unconfirmedTaskResponse, + unconfirmedTaskResponse.http === 200 && codeOf(unconfirmedTaskResponse) === 'A0404', + `HTTP=${unconfirmedTaskResponse.http}, code=${codeOf(unconfirmedTaskResponse)}, requestId=${unconfirmedTaskResponse.requestId || 'missing'}, traceId=${unconfirmedTaskResponse.traceId || 'missing'}`, OPTIONS) + + const confirmableResponse = await context.request('GET', + `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(warehouseId)}`) + const confirmable = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 计划级确认即时版本`, confirmableResponse, '', OPTIONS) + const confirmableVersion = Number(confirmable?.summary?.version) + context.assert(MODULE, `${sourceOrderNo} 放舱后整计划允许确认且尚未就绪 PDA`, confirmableResponse, + Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING' + && confirmable?.summary?.readiness?.canConfirm === true + && confirmable?.summary?.readiness?.readyForPda === false + && confirmable?.summary?.readiness?.pdaTaskId == null, + `version=${confirmable?.summary?.version ?? 'missing'}, canConfirm=${confirmable?.summary?.readiness?.canConfirm}, pdaTaskId=${id(confirmable?.summary?.readiness?.pdaTaskId) || 'none'}`, OPTIONS) + if (!Number.isInteger(confirmableVersion) || confirmable?.summary?.readiness?.canConfirm !== true) return null + + const confirmPath = `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm` + const confirmBody = Object.freeze({ expectedVersion: confirmableVersion }) + const confirmHeaders = Object.freeze({ 'Idempotency-Key': `${operationBase}-plan-confirm`.slice(0, 128) }) + const confirmResponse = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) + const confirmed = context.apiSuccess(MODULE, `计划级确认 ${sourceOrderNo} 并生成唯一 PDA 任务`, confirmResponse, '', OPTIONS) + const confirmedTaskId = id(confirmed?.readiness?.pdaTaskId) + context.assert(MODULE, `${sourceOrderNo} 计划确认返回 CONFIRMED 与 readyForPda`, confirmResponse, + id(confirmed?.planId) === planId && id(confirmed?.containerBookingId) === containerId + && confirmed?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.confirmationStatus === 'CONFIRMED' + && confirmed?.readiness?.readyForPda === true && Boolean(confirmedTaskId) + && Number(confirmed?.version) > confirmableVersion, + `confirmationStatus=${confirmed?.confirmationStatus || 'missing'}, readyForPda=${confirmed?.readiness?.readyForPda}, pdaTaskId=${confirmedTaskId || 'missing'}`, OPTIONS) + if (!confirmedTaskId || confirmed?.confirmationStatus !== 'CONFIRMED') return null + + const confirmReplay = await request(context, 'POST', confirmPath, confirmBody, confirmHeaders) + context.assert(MODULE, `${sourceOrderNo} 计划确认同键同参精确重放`, confirmReplay, + confirmReplay.http === 200 && codeOf(confirmReplay) === '200' + && id(confirmReplay.body?.data?.readiness?.pdaTaskId) === confirmedTaskId + && Number(confirmReplay.body?.data?.version) === Number(confirmed.version), + `HTTP=${confirmReplay.http}, code=${codeOf(confirmReplay)}, requestId=${confirmReplay.requestId || 'missing'}, traceId=${confirmReplay.traceId || 'missing'}`, OPTIONS) + const taskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS) const taskId = id(task?.id) const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id)) context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse, - Boolean(taskId) && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId + taskId === confirmedTaskId && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId && task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1 && Number(pickItem?.pickedPackageCount) === 0, `taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS)