diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 40bee31dc..630b2bdfe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -47,6 +47,13 @@ public interface CarrierScheduleQueryMapper extends BaseMapper SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index 1fdd30cd0..407dbdcee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -41,6 +41,9 @@ public class CarrierScheduleQueryRepository { public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); } + public CarrierScheduleQueryEntity findByContainerBookingId(long containerBookingId) { + return queries.findByContainerBookingId(containerBookingId); + } public List containerActualFacts(List containerBookingIds) { return queries.selectContainerActualFacts(containerBookingIds); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 7ec142ab3..fe432ba49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -262,13 +262,27 @@ public class CarrierScheduleQueryService { ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + CarrierScheduleQueryEntity found = repository.findByContainerBookingId(containerBookingId); + CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId()); String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key); String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request); - CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId, + CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId, + "MANUAL", providerEventId, node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash); - if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId, - fact, normalizedContainerNo(container)); + if (fact != null && query != null) { + CarrierScheduleQueryEntity before = copy(query); + requireManualActualAdvance(query, node, request.actualAt()); + boolean advanced = advanceActualNode(query, node, request.actualAt(), request.observedAt(), + operatorId, operatorName); + CarrierScheduleQueryEntity after = advanced ? required(query.getId()) : query; + addHistory(query.getId(), "MANUAL_NODE", before.getVersion(), after.getVersion(), request.reason(), + before, after, operatorId, operatorName); + nodeOutbox.publish(after, fact, normalizedContainerNo(container)); + } else if (fact != null) { + nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId, + fact, normalizedContainerNo(container)); + } return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index ddbf2e5fd..7449dfedd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -474,6 +474,38 @@ class CarrierScheduleQueryServiceTest { verify(repository).updateState(before, 3, 7L, "admin"); } + @Test + void containerManualNodeJoinsAndAdvancesExistingCarrierTask() { + CarrierScheduleQueryEntity task = task(0, 8); + task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970"); + task.setStatus("READY"); task.setLatestStandardNode("PICKUP_READY"); + task.setLatestNodeObservedAt(NOW.minusHours(1)); + CarrierScheduleQueryEntity stored = copyTask(task); + stored.setVersion(9); stored.setLatestStandardNode("PICKED_UP"); + stored.setLatestNodeObservedAt(NOW); + when(repository.findByContainerBookingId(20L)).thenReturn(task); + when(repository.lock(1L)).thenReturn(task); + when(repository.find(1L)).thenReturn(stored); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + when(repository.updateState(any(), eq(8), eq(7L), eq("admin"))).thenReturn(1); + when(repository.nodesByContainer(20L)).thenReturn(List.of()); + + service.addContainerManualNode(20L, 7L, "admin", "terminal-pickup", + new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest( + "PICKED_UP", NOW, null, NOW, "USLAX", "Los Angeles", "terminal pickup")); + + verify(repository).insertIfAbsent(argThat((CarrierScheduleNodeFactEntity fact) -> Long.valueOf(1L).equals(fact.getQueryId()) + && fact.getContainerBookingId() == 20L && "PICKED_UP".equals(fact.getStandardNode()))); + verify(repository).updateState(task, 8, 7L, "admin"); + verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) -> + "MANUAL_NODE".equals(history.getAction()) && Long.valueOf(1L).equals(history.getQueryId()) + && Integer.valueOf(8).equals(history.getFromVersion()) + && Integer.valueOf(9).equals(history.getToVersion()))); + verify(nodeOutbox).publish(eq(stored), argThat(fact -> Long.valueOf(1L).equals(fact.getQueryId())), + eq("ZCSU7881970")); + verify(nodeOutbox, never()).publishContainerNode(any(), anyLong(), any(), anyString()); + } + @Test void manualActualFactRejectsCurrentOrEarlierBusinessNodeBeforeWritingFact() { CarrierScheduleQueryEntity task = task(0, 3); @@ -771,6 +803,14 @@ class CarrierScheduleQueryServiceTest { return value; } + private static CarrierScheduleQueryEntity copyTask(CarrierScheduleQueryEntity source) { + CarrierScheduleQueryEntity value = task(source.getAttemptCount(), source.getVersion()); + value.setTrackingType(source.getTrackingType()); value.setTrackingNo(source.getTrackingNo()); + value.setStatus(source.getStatus()); value.setLatestStandardNode(source.getLatestStandardNode()); + value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); + return value; + } + private void set(String fieldName, Object value) throws Exception { var field = CarrierScheduleQueryService.class.getDeclaredField(fieldName); field.setAccessible(true); field.set(service, value); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 5d8b39022..e2db4bc29 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 071f1ad17..836860fe0 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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 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 efd86abc8..31e660c3f 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 @@ -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', () => { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index f9e53aa20..38766ede4 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -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) }) 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 1dc7301ab..c39c9eb87 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 @@ -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', () => { diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs index 4c4fc40f0..656c3a5f2 100644 --- a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -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`)