test: sequence terminal drayage around unloading

This commit is contained in:
2026-09-11 01:50:46 +08:00
parent abbacc683e
commit fe278f2ba6
6 changed files with 86 additions and 48 deletions
+2 -2
View File
@@ -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
@@ -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:/)
@@ -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),
@@ -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
@@ -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, /禁止借历史货柜/)
})
@@ -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