From 2a16c91c89928bdf7782b7801fa309a383f2dee3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:48:10 +0800 Subject: [PATCH] test: sequence pickup readiness before overseas receiving --- scripts/admin-test-env-acceptance.mjs | 7 +- .../phase4-carrier-nodes-contract.test.mjs | 14 +++- .../phase4-carrier-nodes.mjs | 66 ++++++++++++++++++- .../phase4-carrier-pickup-ready.mjs | 5 ++ 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 51ba55c58..0c3a005ff 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -29,12 +29,13 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], - ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], + ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], ['phase4-overseas-client-services', ['phase4-overseas-pda']], ['phase4-overseas-dispatch', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], - ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], + ['phase4-carrier-nodes', ['phase4-overseas-pda']], ]) function stableTopologicalOrder(modules) { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 2b88fe588..4a26efff9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -31,13 +31,25 @@ test('focused carrier node execution injects the complete outbound-confirm produ 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', - 'phase3-carrier-schedule-queries', 'phase4-carrier-nodes'] + 'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready', + 'phase4-overseas-pda', 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) } }) +test('real lifecycle opens the shipping gate before PDA and records pickup and empty return afterwards', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes']) + const pickup = modules.indexOf('phase4-carrier-pickup-ready') + const pda = modules.indexOf('phase4-overseas-pda') + const completion = modules.indexOf('phase4-carrier-nodes') + assert.ok(pickup >= 0 && pickup < pda) + assert.ok(pda < completion) + assert.match(source, /phase4CarrierPickupReady/) + assert.match(source, /STANDARD_NODES\.slice\(STANDARD_NODES\.indexOf\('PICKED_UP'\)\)/) +}) + test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index bd0bea3a9..903cb0da3 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -100,7 +100,7 @@ function blocker(context, name, evidence, response = null) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) } -export async function run(context) { +export async function runUntilPickupReady(context) { const carriersResponse = await context.request('GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse, @@ -188,7 +188,8 @@ export async function run(context) { const frozenLatestActualMs = frozenLatestActual == null ? Number.NaN : Date.parse(`${frozenLatestActual}Z`) const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 - for (let index = 0; index < STANDARD_NODES.length; index += 1) { + const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY') + for (let index = 0; index <= pickupReadyIndex; index += 1) { const standardNode = STANDARD_NODES[index] const actualAt = localIso(new Date(baseActual + index * 60_000)) const body = manualNodePayload({ expectedVersion: version, standardNode, @@ -214,6 +215,67 @@ export async function run(context) { detail = updated } + const pickupTask = detail.query.query + const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + const expectedPickupNodes = STANDARD_NODES.slice(0, pickupReadyIndex + 1) + context.assert(MODULE, 'PDA开始前已覆盖至PICKUP_READY且任务尚未停止', detailResponse, + expectedPickupNodes.every((node) => actualNodes.has(node)) && pickupTask.status !== 'STOPPED' + && pickupTask.latestActualNode === 'PICKUP_READY', + `actualNodes=${[...actualNodes].join(',')}, status=${pickupTask.status}`, OPTIONS) + context.createdResources.phase4CarrierPickupReady = { + marker: context.marker, queryId, version: Number(pickupTask.version), + latestActualAt: pickupTask.latestActualAt, ready: true, + } +} + +export async function run(context) { + let pickup = context.createdResources?.phase4CarrierPickupReady + if (pickup?.marker !== context.marker || pickup?.ready !== true) { + await runUntilPickupReady(context) + pickup = context.createdResources?.phase4CarrierPickupReady + } + if (!pickup?.queryId) return + + const queryId = id(pickup.queryId) + const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) + let detail = context.apiSuccess(MODULE, 'PDA收货完成后恢复本轮船节点任务', detailResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '恢复任务详情DTO完整') + const pickupTask = detail?.query?.query + if (pickupTask?.latestActualNode !== 'PICKUP_READY' || pickupTask?.status === 'STOPPED') { + blocker(context, 'PDA收货后船节点未停留在PICKUP_READY,禁止伪造后续节点', + `latest=${pickupTask?.latestActualNode || 'missing'}, status=${pickupTask?.status || 'missing'}`, detailResponse) + return + } + + let version = Number(pickupTask.version) + const frozenLatestActualMs = Date.parse(`${pickupTask.latestActualAt}Z`) + const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 + const remainingNodes = STANDARD_NODES.slice(STANDARD_NODES.indexOf('PICKED_UP')) + for (let index = 0; index < remainingNodes.length; index += 1) { + const standardNode = remainingNodes[index] + const actualAt = localIso(new Date(baseActual + index * 60_000)) + const body = manualNodePayload({ expectedVersion: version, standardNode, + observedAt: localIso(new Date()), actualAt, marker: context.marker, + index: STANDARD_NODES.indexOf(standardNode) + 1 }) + const key = `${context.marker}-carrier-actual-${String(STANDARD_NODES.indexOf(standardNode) + 1).padStart(2, '0')}`.slice(0, 128) + const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, + context.token, { 'Idempotency-Key': key }) + const updated = context.apiSuccess(MODULE, `PDA完成后补录实际节点${standardNode}`, response, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) + const updatedTask = updated?.query?.query + const isEmptyReturned = standardNode === 'EMPTY_RETURNED' + context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, + updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt + && (isEmptyReturned + ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null + : updatedTask.status !== 'STOPPED'), + `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) + version = Number(updatedTask.version) + detail = updated + } + const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, diff --git a/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs b/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs new file mode 100644 index 000000000..1201dc813 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs @@ -0,0 +1,5 @@ +import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' + +export async function run(context) { + await runUntilPickupReady(context) +}