diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index ce35d2c11..a285a3541 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,4 +1,5 @@ import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' +import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' const MODULE = 'phase3-container-loading' const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } @@ -258,14 +259,21 @@ export async function run(context) { Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1, `linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS) if (!pushedLink?.id) return + const postPushPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS) + context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse, + postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(pushedLink?.loadingPlanVersion) + && Number(postPushPlan?.version) > Number(assigned.version), + `pushResponseVersion=${pushed?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${pushedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return const pushedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse, pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404', responseEvidence(pushedTaskResponse), OPTIONS) - const pushBaseline = JSON.stringify({ - plan: pushed, detail: pushedDetail, links: pushedLinks, + const pushBaseline = projectPushPreloadStability({ + plan: postPushPlan, detail: pushedDetail, links: pushedLinks, task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) }, }) const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) @@ -279,15 +287,16 @@ export async function run(context) { const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) const replayTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const pushReplaySnapshot = JSON.stringify({ + const pushReplaySnapshot = projectPushPreloadStability({ plan: replayPlan, detail: replayDetail, links: replayLinks, task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) }, }) + const pushReplayDiff = diffPushPreloadStability(pushBaseline, pushReplaySnapshot) context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse, - pushBaseline === pushReplaySnapshot && codeOf(replayTaskResponse) === 'A0404', - `baselineBytes=${pushBaseline.length}, replayBytes=${pushReplaySnapshot.length}`, OPTIONS) + pushReplayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404', + `changed=${pushReplayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS) const pushSameKeyDifferentBody = await request(context, 'POST', pushRequest.path, - { expectedVersion: Number(pushed.version) }, pushRequest.headers) + { expectedVersion: Number(postPushPlan.version) }, pushRequest.headers) context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody, failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS) const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, @@ -304,21 +313,22 @@ export async function run(context) { const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) const rejectedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const rejectedSnapshot = JSON.stringify({ + const rejectedSnapshot = projectPushPreloadStability({ plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks, task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) }, }) + const rejectedDiff = diffPushPreloadStability(pushBaseline, rejectedSnapshot) context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse, failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate) - && pushBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404', - `baselineBytes=${pushBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS) - if (pushBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return + && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404', + `changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS) + if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return const realContainerNo = `TST-${suffix}`.slice(0, 64) const transportResponse = await request(context, 'PATCH', `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64), - expectedVersion: Number(pushed.version), operationReason: context.marker, + expectedVersion: Number(postPushPlan.version), operationReason: context.marker, }, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) }) const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS) context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0c8970f4b..eb321fd9b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,3 +1,5 @@ +import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' + const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' @@ -5,6 +7,9 @@ const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function responseEvidence(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} function failure(response) { return [400, 401, 403, 404, 409, 503].includes(response?.http) || (response?.http === 200 && codeOf(response) !== '200') @@ -205,6 +210,13 @@ export async function run(context) { && Number(link?.allocatedPackageCount) === 1, `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS) if (!link?.id) return + const postPushPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS) + context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse, + postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(link?.loadingPlanVersion) + && Number(postPushPlan?.version) > Number(assignedLoadingPlan.version), + `pushResponseVersion=${pushedLoadingPlan?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return const missingContainerTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404' @@ -213,8 +225,8 @@ export async function run(context) { `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS) if (!missingContainerTaskAbsent) return - const replayBaseline = JSON.stringify({ - plan: pushedLoadingPlan, detail: pushedDetail, links: pushedLinks, + const replayBaseline = projectPushPreloadStability({ + plan: postPushPlan, detail: pushedDetail, links: pushedLinks, task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) }, }) const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) @@ -228,16 +240,17 @@ export async function run(context) { const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) const replayTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const replaySnapshot = JSON.stringify({ + const replaySnapshot = projectPushPreloadStability({ plan: replayPlan, detail: replayDetail, links: replayLinks, task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) }, }) + const replayDiff = diffPushPreloadStability(replayBaseline, replaySnapshot) context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse, - replayBaseline === replaySnapshot && codeOf(replayTaskResponse) === 'A0404', - `baselineBytes=${replayBaseline.length}, replayBytes=${replaySnapshot.length}`, OPTIONS) + replayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404', + `changed=${replayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS) const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path, - { expectedVersion: Number(pushedLoadingPlan.version) }, pushRequest.headers) + { expectedVersion: Number(postPushPlan.version) }, pushRequest.headers) context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody, failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS) const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, @@ -254,19 +267,20 @@ export async function run(context) { const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) const rejectedTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const rejectedSnapshot = JSON.stringify({ + const rejectedSnapshot = projectPushPreloadStability({ plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks, task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) }, }) + const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot) context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse, failure(sameKeyDifferentBody) && failure(newKeyDuplicate) - && replayBaseline === rejectedSnapshot && codeOf(rejectedTaskResponse) === 'A0404', - `baselineBytes=${replayBaseline.length}, rejectedBytes=${rejectedSnapshot.length}`, OPTIONS) - if (replayBaseline !== rejectedSnapshot || codeOf(rejectedTaskResponse) !== 'A0404') return + && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404', + `changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS) + if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return const transportResponse = await request(context, 'PATCH', `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { - containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(pushedLoadingPlan.version), + containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(postPushPlan.version), operationReason: `${context.marker}-bind-container-no`, }, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) }) const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS) 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 4b8e66ccd..0f882e700 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 @@ -5,6 +5,7 @@ import test from 'node:test' import { selectCustomsFlow } from './phase3-customs.mjs' import { buildFinalizeResource } from './phase3-loading-finalize.mjs' import { selectContainerLoadingFixture, selectPlatformChannelIsolationPair } from './phase3-container-loading.mjs' +import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' function source(name) { @@ -132,9 +133,53 @@ test('push replay and both conflict modes preserve the complete pre-PDA snapshot assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/) assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/) assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/) + assert.match(value, /projectPushPreloadStability/) + assert.match(value, /diffPushPreloadStability/) + assert.match(value, /首次 push 后读取事务完成态柜次计划/) + assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/) + assert.match(value, /plan: postPushPlan, detail:/) + assert.match(value, /expectedVersion: Number\(postPushPlan\.version\)/) + assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/) + assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/) } }) +test('push stability projection sorts unordered facts and reports persisted field drift', () => { + const baseline = projectPushPreloadStability({ + plan: { id: '10', bookingId: '20', status: 'EDITING', version: 2, + items: [{ id: '2', inboundId: '42', packageCount: 1, version: 1 }, + { id: '1', inboundId: '41', packageCount: 1, version: 1 }] }, + detail: { summary: { planId: '10', containerBookingId: '20', status: 'EDITING', version: 2, + readiness: { preloadPushed: true, preloadLinkCount: 2, confirmationStatus: 'PENDING', + blockingReasons: ['CONTAINER_NO_MISSING', 'OCEAN_BOOKING_NOT_LINKED'] } }, + items: [{ itemId: '2', inboundId: '42', packageCount: 1 }, { itemId: '1', inboundId: '41', packageCount: 1 }], + histories: [{ historyId: '2', actionType: 'PUSH' }, { historyId: '1', actionType: 'CREATE' }] }, + links: [{ id: '2', inboundId: '42', loadingPlanId: '10', allocatedPackageCount: 1 }, + { id: '1', inboundId: '41', loadingPlanId: '10', allocatedPackageCount: 1 }], + task: { http: 200, code: 'A0404' }, + }) + const reordered = projectPushPreloadStability({ + plan: { id: '10', bookingId: '20', status: 'EDITING', version: 2, + items: [{ id: '1', inboundId: '41', packageCount: 1, version: 1 }, + { id: '2', inboundId: '42', packageCount: 1, version: 1 }], updatedAt: 'ignored' }, + detail: { summary: { planId: '10', containerBookingId: '20', status: 'EDITING', version: 2, + readiness: { preloadPushed: true, preloadLinkCount: 2, confirmationStatus: 'PENDING', + blockingReasons: ['OCEAN_BOOKING_NOT_LINKED', 'CONTAINER_NO_MISSING'] } }, + items: [{ itemId: '1', inboundId: '41', packageCount: 1 }, { itemId: '2', inboundId: '42', packageCount: 1 }], + histories: [{ historyId: '1', actionType: 'CREATE' }, { historyId: '2', actionType: 'PUSH' }] }, + links: [{ id: '1', inboundId: '41', loadingPlanId: '10', allocatedPackageCount: 1 }, + { id: '2', inboundId: '42', loadingPlanId: '10', allocatedPackageCount: 1 }], + task: { http: 200, code: 'A0404' }, + }) + assert.deepEqual(diffPushPreloadStability(baseline, reordered), []) + const drifted = structuredClone(reordered) + drifted.plan.version = 3 + drifted.links[0].preloadVersion = 2 + assert.deepEqual(diffPushPreloadStability(baseline, drifted), [ + 'links[0].preloadVersion:null->2', 'plan.version:2->3', + ]) +}) + test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => { const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') @@ -212,7 +257,7 @@ test('container loading arranges a simulation before linking a confirmed real bo } assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/) assert.match(value, /containerAllocations\.length === 0/) - assert.match(value, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/) + assert.match(value, /expectedVersion: Number\(postPushPlan\.version\), operationReason: 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/admin-test-env-modules/push-preload-stability.mjs b/scripts/admin-test-env-modules/push-preload-stability.mjs new file mode 100644 index 000000000..3ab5e5d9f --- /dev/null +++ b/scripts/admin-test-env-modules/push-preload-stability.mjs @@ -0,0 +1,109 @@ +function id(value) { return value == null ? null : String(value) } +function number(value) { return value == null || Number.isNaN(Number(value)) ? null : Number(value) } +function text(value) { return value == null ? null : String(value) } +function bool(value) { return value === true } + +function sorted(values, key) { + return [...(Array.isArray(values) ? values : [])] + .sort((left, right) => key(left).localeCompare(key(right))) +} + +function planItem(item) { + return { + id: id(item?.id), loadingPlanId: id(item?.loadingPlanId), inboundId: id(item?.inboundId), + inboundDetailId: id(item?.inboundDetailId), accountId: id(item?.accountId), orderId: id(item?.orderId), + packageCount: number(item?.packageCount), loadSequence: number(item?.loadSequence), + frozenAt: text(item?.frozenAt), version: number(item?.version), deleted: bool(item?.deleted), + } +} + +function detailItem(item) { + return { + itemId: id(item?.itemId), accountId: id(item?.accountId), inboundId: id(item?.inboundId), + inboundDetailId: id(item?.inboundDetailId), packageCount: number(item?.packageCount), + palletCount: number(item?.palletCount), loadSequence: number(item?.loadSequence), + frozenAt: text(item?.frozenAt), + } +} + +function readiness(value) { + return { + preloadPushed: bool(value?.preloadPushed), containerNoReady: bool(value?.containerNoReady), + preloadLinkCount: number(value?.preloadLinkCount), confirmedPreloadCount: number(value?.confirmedPreloadCount), + oceanBookingId: id(value?.oceanBookingId), oceanBookingStatus: text(value?.oceanBookingStatus), + pdaTaskId: id(value?.pdaTaskId), pdaTaskStatus: text(value?.pdaTaskStatus), + confirmationStatus: text(value?.confirmationStatus), canConfirm: bool(value?.canConfirm), + readyForPda: bool(value?.readyForPda), + blockingReasons: sorted(value?.blockingReasons, (item) => text(item) || '').map(text), + } +} + +function history(value) { + return { + historyId: id(value?.historyId), itemId: id(value?.itemId), actionType: text(value?.actionType), + fromStatus: text(value?.fromStatus), toStatus: text(value?.toStatus), + beforeSnapshot: text(value?.beforeSnapshot), afterSnapshot: text(value?.afterSnapshot), + operationReason: text(value?.operationReason), occurredAt: text(value?.occurredAt), + } +} + +function link(value) { + return { + id: id(value?.id), tenantId: id(value?.tenantId), inboundId: id(value?.inboundId), + preloadPlanId: id(value?.preloadPlanId), preloadStatus: text(value?.preloadStatus), + preloadVersion: number(value?.preloadVersion), loadingPlanId: id(value?.loadingPlanId), + containerBookingId: id(value?.containerBookingId), containerNo: text(value?.containerNo), + loadingPlanStatus: text(value?.loadingPlanStatus), loadingPlanVersion: number(value?.loadingPlanVersion), + allocatedPackageCount: number(value?.allocatedPackageCount), linkedAt: text(value?.linkedAt), + } +} + +export function projectPushPreloadStability({ plan, detail, links, task }) { + const summary = detail?.summary || {} + return { + plan: { + id: id(plan?.id), bookingId: id(plan?.bookingId), status: text(plan?.status), + containerNo: text(plan?.containerNo), voyageNo: text(plan?.voyageNo), version: number(plan?.version), + items: sorted(plan?.items, (item) => id(item?.id) || '').map(planItem), + }, + detail: { + summary: { + planId: id(summary?.planId), containerBookingId: id(summary?.containerBookingId), + containerNo: text(summary?.containerNo), cabinetNo: text(summary?.cabinetNo), + status: text(summary?.status), version: number(summary?.version), + skuCount: number(summary?.skuCount), totalPackageCount: number(summary?.totalPackageCount), + readiness: readiness(summary?.readiness), + }, + items: sorted(detail?.items, (item) => id(item?.itemId) || '').map(detailItem), + histories: sorted(detail?.histories, + (item) => `${id(item?.historyId) || ''}:${text(item?.occurredAt) || ''}`).map(history), + }, + links: sorted(links, (item) => id(item?.id) || '').map(link), + task: { http: number(task?.http), code: text(task?.code) }, + } +} + +function display(value) { + if (value === undefined) return 'missing' + return JSON.stringify(value) +} + +export function diffPushPreloadStability(before, after) { + const changes = [] + function visit(left, right, path) { + if (Object.is(left, right)) return + if (Array.isArray(left) && Array.isArray(right)) { + const length = Math.max(left.length, right.length) + for (let index = 0; index < length; index += 1) visit(left[index], right[index], `${path}[${index}]`) + return + } + if (left && right && typeof left === 'object' && typeof right === 'object') { + const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort() + for (const key of keys) visit(left[key], right[key], path ? `${path}.${key}` : key) + return + } + changes.push(`${path}:${display(left)}->${display(right)}`) + } + visit(before, after, '') + return changes +} diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 77a62fec8..ef340f731 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -69,7 +69,7 @@ test('test-environment loading chains push without a number and open PDA only af const bridgeLoading = readFileSync( resolve(scriptsDir, 'admin-test-env-modules', 'phase3-loading.mjs'), 'utf8') - assert.match(containerLoading, /expectedVersion: Number\(pushed\.version\), operationReason: context\.marker/) + assert.match(containerLoading, /expectedVersion: Number\(postPushPlan\.version\), operationReason: context\.marker/) const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细') const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')