fix: link terminal drayage carrier events

This commit is contained in:
2026-09-11 02:20:50 +08:00
parent fe278f2ba6
commit 81f4f9163c
10 changed files with 99 additions and 11 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 |
| Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 |
| 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 |
| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 |
| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。货柜达到 `PICKUP_READY` 后先经正式码头拖车接口完成选车队、预约和提柜,PDA 卸柜后再还柜;船节点模块只读核对最终回写,不直接伪造最后两个节点。人工船节点的 `observedAt` 使用运行机本地墙钟时间,避免无时区 `LocalDateTime` 被 UTC 偏移倒序 |
| 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`(含国内 PDA Node 全量测试)、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
| 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 |
+1 -1
View File
@@ -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 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING` 供应商完成选车队、预约和实际提柜,PDA 卸柜完成后再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。
尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再通过柜级预览与确认自动生成逐票计划和实体托盘;PDA 必须消费这些计划托盘,不得临时另建脱离计划的托盘。货柜到达 `PICKUP_READY` 后先复用公共 `ACTIVE + TRUCKING` 供应商完成选车队、预约和实际提柜;PDA 卸柜完成后,有界等待跨区卸柜事件回写国内聚合为 `UNLOADED`,再确认实际还柜。最后的船节点模块仅回读两次业务动作同步生成的 `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
@@ -71,6 +71,8 @@ test('canonical states, ten ordered nodes, exception codes and complete payload
test('actual node timestamps advance from the frozen outbound-confirm fact instead of the runner timezone', () => {
assert.match(source, /Date\.parse\(`\$\{frozenLatestActual\}Z`\)/)
assert.match(source, /frozenLatestActualMs\) \? Date\.now\(\) : frozenLatestActualMs/)
assert.equal((source.match(/observedAt: localWallIso\(new Date\(\)\)/g) || []).length, 3)
assert.match(source, /const observedAt = localWallIso\(new Date\(\)\)/)
})
test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => {
@@ -38,6 +38,11 @@ function monotonicRejected(response) {
&& Boolean(response?.requestId) && Boolean(response?.traceId)
}
function localIso(value) { return value.toISOString().slice(0, 19) }
function localWallIso(value) {
const pad = (part) => String(part).padStart(2, '0')
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`
+ `T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`
}
export function markerOwnedCarrierQuery(context) {
const resource = context?.createdResources?.phase3CarrierScheduleQuery
@@ -167,7 +172,7 @@ export async function runUntilPickupReady(context) {
ownershipMatches, `registered=${queryId}, actual=${id(initialTask?.id) || 'missing'}`, OPTIONS)
if (!ownershipMatches) return
const observedAt = localIso(new Date())
const observedAt = localWallIso(new Date())
const estimatedAt = localIso(new Date(Date.now() + 30 * 24 * 60 * 60_000))
const estimatedBody = manualNodePayload({ expectedVersion: Number(initialTask.version),
standardNode: 'ARRIVED_DESTINATION_PORT', observedAt, estimatedAt, marker: context.marker })
@@ -226,7 +231,7 @@ export async function runUntilPickupReady(context) {
const sameNodeSnapshot = actualProgressSnapshot(detail)
const sameNodeBody = manualNodePayload({ expectedVersion: version,
standardNode: detail.query.query.latestActualNode, observedAt: localIso(new Date()),
standardNode: detail.query.query.latestActualNode, observedAt: localWallIso(new Date()),
actualAt: localIso(new Date(baseActual)), marker: context.marker, index: frozenLatestNodeIndex + 1 })
const sameNodeResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
@@ -250,7 +255,7 @@ export async function runUntilPickupReady(context) {
const standardNode = STANDARD_NODES[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: index + 1 })
observedAt: localWallIso(new Date()), actualAt, marker: context.marker, index: index + 1 })
const key = `${context.marker}-carrier-actual-${String(index + 1).padStart(2, '0')}`.slice(0, 128)
const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body,
context.token, { 'Idempotency-Key': key })
@@ -275,7 +280,7 @@ export async function runUntilPickupReady(context) {
const progressedSnapshot = actualProgressSnapshot(detail)
const earlierNode = STANDARD_NODES[index - 1]
const earlierBody = manualNodePayload({ expectedVersion: version, standardNode: earlierNode,
observedAt: localIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
observedAt: localWallIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
marker: context.marker, index })
const earlierResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
@@ -84,6 +84,8 @@ test('terminal drayage prepares pickup before unloading and finalizes return bef
const finalCarrier = modules.indexOf('phase4-carrier-nodes')
assert.ok(pickup >= 0 && pickup < pda)
assert.ok(pda < drayage && drayage < finalCarrier)
assert.match(source, /attempt <= 30/)
assert.match(source, /initialData\?\.status === 'UNLOADED'/)
})
test('module uses only public business endpoints and reuses public trucking master data', () => {
@@ -130,7 +130,22 @@ export async function run(context) {
return
}
const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}`
const initial = await api(context, 'GET', '卸柜后读取目的港码头拖车聚合', path)
let initialResponse = null
let initialData = null
let pollRequests = 0
for (let attempt = 1; attempt <= 30; attempt += 1) {
pollRequests = attempt
initialResponse = await context.request('GET', path, undefined, context.token)
initialData = success(initialResponse) ? initialResponse.body?.data : null
if (initialData?.status === 'UNLOADED'
&& initialData?.allowedActions?.includes('RECORD_EMPTY_RETURN')) break
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const initial = {
response: initialResponse,
data: context.apiSuccess(MODULE, '卸柜后等待跨区事件并读取目的港码头拖车聚合', initialResponse,
`pollRequests=${pollRequests}; ${trace(initialResponse)}`, OPTIONS),
}
context.assert(MODULE, '卸柜聚合保留提柜事实并开放还柜', initial.response,
id(initial.data?.containerBookingId) === target.containerBookingId
&& id(initial.data?.containerNo) === id(target.completed.containerNo)
@@ -138,7 +153,7 @@ export async function run(context) {
&& 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)
`pollRequests=${pollRequests}, 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`)