From fe278f2ba6ac1acf7c61c20c38a7978d7d07eec8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 01:50:46 +0800 Subject: [PATCH] test: sequence terminal drayage around unloading --- scripts/admin-test-env-modules/README.md | 4 +- ...overseas-client-services-contract.test.mjs | 1 + .../phase4-overseas-client-services.mjs | 1 + .../phase4-overseas-pda.mjs | 8 ++ .../phase5-terminal-drayage-contract.test.mjs | 19 ++-- .../phase5-terminal-drayage.mjs | 101 +++++++++++------- 6 files changed, 86 insertions(+), 48 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index b9a02207c..071f1ad17 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -100,7 +100,7 @@ export async function run(context) { | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | -| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜后复用 `ACTIVE + TRUCKING` 主数据,覆盖选车队、预约提柜、实际提柜、实际还柜及四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | +| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | | 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | @@ -110,7 +110,7 @@ export async function run(context) { 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定先为本轮 `LOADED` 柜次完成国内 `CUSTOMS_DECLARATION` 与 PDA 装柜证据,再确认国内出仓;离仓后才维护 `DESTINATION_CLEARANCE`、`DELIVERY_ORDER` 两类目的港文件,PU 使用柜级数字码而不是文件,随后核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 -尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 +尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING` 供应商完成选车队、预约和实际提柜,PDA 卸柜完成后再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index ceb1b0e98..ab2b8a5ba 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -30,6 +30,7 @@ test('module never borrows historical order or inventory candidates', () => { }) test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => { + assert.match(source, /deliveryMethod: 'TRUCK', printShippingLabel: false/) assert.match(source, /chargeAmount: 0\.01/) assert.match(source, /cartonMark:/) assert.match(source, /fbaCartonMark:/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 564cb9a0c..278a686a6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -108,6 +108,7 @@ async function verifyRelabel(context, token, order) { operationWarehouseId: order.row.overseasWarehouseId, labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006', chargeAmount: 0.01, + deliveryMethod: 'TRUCK', printShippingLabel: false, remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255), items: [{ sourceInventoryBalanceId: id(source.inventoryBalanceId), diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d784d966c..aa26e51a1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -1,3 +1,5 @@ +import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' + const MODULE = 'phase4-overseas-pda' const SCENARIO = 'phase4-overseas-pda-container-receiving' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -908,6 +910,12 @@ export async function run(context) { 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') return } + if (producers.length && context.options?.modules?.includes('phase5-terminal-drayage') + && !(await prepareTerminalDrayage(context, producers[0]))) { + blockerGap(context, '目的港码头拖车提柜前置', + '本轮首个PICKUP_READY货柜未能在卸柜前完成选车队、预约和实际提柜') + return + } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` context.createdResources.phase4OverseasPda.finalStatus = null diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs index 40d6cf557..1dc7301ab 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { run } from './phase5-terminal-drayage.mjs' +import { prepareTerminalDrayage, run } from './phase5-terminal-drayage.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8') @@ -19,11 +19,12 @@ test('four real command payloads mutate contract fields and preserve remarks and let conflicts = 0 let replays = 0 const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline-request', traceId: 'offline-trace' }) + const producer = { containerBookingId, containerNo, shippingReady: true, + carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' } const context = { marker, token: 'offline-only', createdResources: { - phase4InboundProducerFixtures: { marker, containers: [{ containerBookingId, containerNo, - shippingReady: true, carrierScheduleQueryId: '9223372036854775805', carrierLatestActualAt: '2026-09-09T01:00:00' }] }, + phase4InboundProducerFixtures: { marker, containers: [producer] }, phase4OverseasPda: { marker, finalStatus: 'COMPLETED', completedContainerFacts: [{ containerBookingId, containerNo }] }, }, apiSuccess(_module, name, result) { assert.equal(result.body.code, '200', name); return result.body.data }, @@ -58,13 +59,15 @@ test('four real command payloads mutate contract fields and preserve remarks and state = { ...state, version: state.version + 1 } if (index === 0) Object.assign(state, { fleetSupplierId: body.fleetSupplierId, fleetSupplierName: body.fleetSupplierName, allowedActions: ['SCHEDULE_PICKUP'] }) if (index === 1) state.pickupAppointmentAt = body.pickupAppointmentAt - if (index === 2) Object.assign(state, { status: 'UNLOADED', actualPickupAt: body.actualAt, unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }) + if (index === 2) Object.assign(state, { status: 'PICKED_UP', actualPickupAt: body.actualAt, allowedActions: [] }) if (index === 3) Object.assign(state, { status: 'EMPTY_RETURNED', actualEmptyReturnAt: body.actualAt, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED' } }) history.push({ action: actions[index], fromVersion: index, toVersion: index + 1, reason: body[field] }) commands.set(key, { body: structuredClone(body), state: structuredClone(state) }) return response(state) }, } + await prepareTerminalDrayage(context, producer) + state = { ...state, status: 'UNLOADED', unloadingCompletedAt: '2026-09-09T04:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] } await run(context) assert.equal(commands.size, 4) assert.equal(replays, 4) @@ -73,7 +76,7 @@ test('four real command payloads mutate contract fields and preserve remarks and assert.equal(context.createdResources.phase5TerminalDrayage.finalStatus, 'EMPTY_RETURNED') }) -test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => { +test('terminal drayage prepares pickup before unloading and finalizes return before carrier validation', () => { const modules = normalizeModuleOrder(['phase4-carrier-nodes']) const pickup = modules.indexOf('phase4-carrier-pickup-ready') const pda = modules.indexOf('phase4-overseas-pda') @@ -106,12 +109,10 @@ test('all four commands cover idempotent replay hash conflict and lifecycle sema test('target is strictly current-marker pickup and completed-PDA container', () => { assert.match(source, /matrix\?\.marker !== context\.marker/) - assert.match(source, /pda\?\.marker !== context\.marker/) + assert.match(source, /prepared\?\.marker !== context\.marker/) assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/) assert.match(source, /completedContainerFacts/) - assert.match(source, /item\?\.shippingReady === true/) - assert.match(source, /carrierScheduleQueryId/) - assert.match(source, /carrierLatestActualAt/) + assert.match(source, /producer\?\.shippingReady !== true/) assert.match(source, /phase4CarrierPickupReady =/) assert.match(source, /禁止借历史货柜/) }) diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs index 580fe9e7d..4c4fc40f0 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -20,22 +20,15 @@ function blocker(context, name, evidence, response = null) { context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) } -function fixture(context) { - const matrix = context.createdResources?.phase4InboundProducerFixtures +function completedTarget(context) { + const prepared = context.createdResources?.phase5TerminalDrayage?.prepared const pda = context.createdResources?.phase4OverseasPda - if (matrix?.marker !== context.marker || pda?.marker !== context.marker + if (prepared?.marker !== context.marker || pda?.marker !== context.marker || pda?.finalStatus !== 'COMPLETED') return null - const producers = Array.isArray(matrix.containers) ? matrix.containers : [] - for (const completed of pda.completedContainerFacts || []) { - const producer = producers.find((item) => id(item?.containerBookingId) === id(completed?.containerBookingId) - && id(item?.containerNo) === id(completed?.containerNo) - && item?.shippingReady === true && id(item?.carrierScheduleQueryId) - && item?.carrierLatestActualAt) - if (producer) return { - matrix, producer, pda, completed, containerBookingId: id(producer.containerBookingId), - } - } - return null + const completed = (pda.completedContainerFacts || []).find((item) => + id(item?.containerBookingId) === id(prepared.containerBookingId) + && id(item?.containerNo) === id(prepared.containerNo)) + return completed ? { ...prepared, pda, completed } : null } async function command(context, target, action, path, body) { const key = `${context.marker}-terminal-${path}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 128) @@ -58,32 +51,37 @@ async function command(context, target, action, path, body) { return first } -export async function run(context) { - context.createdResources.phase5TerminalDrayage = { +export async function prepareTerminalDrayage(context, producer) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + if (matrix?.marker !== context.marker || !producer || producer?.shippingReady !== true + || !id(producer?.containerBookingId) || !id(producer?.containerNo) + || !id(producer?.carrierScheduleQueryId) || !producer?.carrierLatestActualAt) { + blocker(context, '取得本轮PICKUP_READY货柜执行码头提柜前置', + `matrix=${matrix?.marker || 'missing'}, container=${id(producer?.containerNo) || 'missing'}; 禁止借历史货柜`) + return null + } + const resource = context.createdResources.phase5TerminalDrayage || { marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null, cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence', } - const target = fixture(context) - if (!target) { - blocker(context, '取得本轮PICKUP_READY且已卸柜货柜', - `matrix=${context.createdResources?.phase4InboundProducerFixtures?.marker || 'missing'}, shippingReady=${context.createdResources?.phase4InboundProducerFixtures?.containers?.filter((item) => item?.shippingReady === true).length ?? 0}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`) - return - } + context.createdResources.phase5TerminalDrayage = resource + const target = { marker: context.marker, producer, containerBookingId: id(producer.containerBookingId), + containerNo: id(producer.containerNo) } const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}` - const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path) - context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response, + const initial = await api(context, 'GET', '卸柜前读取目的港码头拖车初始聚合', path) + context.assert(MODULE, 'PICKUP_READY货柜允许选择车队', initial.response, id(initial.data?.containerBookingId) === target.containerBookingId - && id(initial.data?.containerNo) === id(target.completed.containerNo) + && id(initial.data?.containerNo) === target.containerNo && Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'), `status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) - if (!success(initial.response)) return + if (!success(initial.response) || !initial.data?.allowedActions?.includes('SELECT_FLEET')) return null const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商', '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100') const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE') if (!fleet) { blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response) - return + return null } const selected = await command(context, target, '选择车队', 'fleet', { expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id), @@ -92,9 +90,9 @@ export async function run(context) { context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response, id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name && selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS) - if (!success(selected.response)) return + if (!success(selected.response)) return null - const latestActual = Date.parse(`${target.producer.carrierLatestActualAt}Z`) + const latestActual = Date.parse(`${producer.carrierLatestActualAt}Z`) const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000)) const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', { expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker, @@ -103,22 +101,51 @@ export async function run(context) { scheduled.data?.pickupAppointmentAt === appointmentAt && Number(scheduled.data?.version) === Number(selected.data.version) + 1, trace(scheduled.response), OPTIONS) - if (!success(scheduled.response)) return + if (!success(scheduled.response)) return null const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000)) const picked = await command(context, target, '确认实际提柜', 'pickup', { expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, remark: context.marker, }) - context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response, - picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt - && picked.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), trace(picked.response), OPTIONS) - if (!success(picked.response)) return + context.assert(MODULE, '实际提柜同步PICKED_UP并等待海外仓卸柜', picked.response, + picked.data?.status === 'PICKED_UP' && picked.data?.actualPickupAt === pickupAt, + trace(picked.response), OPTIONS) + if (!success(picked.response) || picked.data?.status !== 'PICKED_UP') return null + resource.prepared = { ...target, appointmentAt, pickupAt, pickedVersion: Number(picked.data.version) } + resource.containerBookingId = target.containerBookingId + resource.containerNo = target.containerNo + return resource.prepared +} - const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`) - const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) +export async function run(context) { + const resource = context.createdResources.phase5TerminalDrayage || { + marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null, + cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence', + } + context.createdResources.phase5TerminalDrayage = resource + const target = completedTarget(context) + if (!target) { + blocker(context, '取得本轮已提柜且已卸柜货柜', + `prepared=${resource.prepared?.marker || 'missing'}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`) + return + } + const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}` + const initial = await api(context, 'GET', '卸柜后读取目的港码头拖车聚合', path) + context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response, + id(initial.data?.containerBookingId) === target.containerBookingId + && id(initial.data?.containerNo) === id(target.completed.containerNo) + && Number(initial.data?.version) === Number(target.pickedVersion) + && initial.data?.status === 'UNLOADED' + && initial.data?.actualPickupAt === target.pickupAt + && initial.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), + `status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) + if (!success(initial.response)) return + + const unloadingAt = Date.parse(`${initial.data?.unloadingCompletedAt}Z`) + const returnBase = Math.max(Date.parse(`${target.pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) const returned = await command(context, target, '确认实际还柜', 'empty-return', { - expectedVersion: Number(picked.data.version), actualAt: returnedAt, remark: context.marker, + expectedVersion: Number(initial.data.version), actualAt: returnedAt, remark: context.marker, }) context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response, returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt