From 2ebea9e78950f2ab3351ded3535972a211c8ffc5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 15:41:03 +0800 Subject: [PATCH] fix(firstleg): allow audited carrier node time corrections --- ...latformCarrierScheduleQueryController.java | 2 + .../service/CarrierScheduleQueryService.java | 31 ++++++++++++- .../openapi/carrier-schedule-query-v1.yaml | 6 ++- ...rrierScheduleQueryOpenApiContractTest.java | 3 ++ .../CarrierScheduleQueryServiceTest.java | 46 +++++++++++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 4153fa1e2..025e591d1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -97,6 +97,8 @@ public class PlatformCarrierScheduleQueryController { @PostMapping("/{queryId}/manual-nodes") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") + @Operation(summary = "人工补录预计或实际标准船节点", + description = "预计节点不推进实际进度;同级当前节点可追加审计事实并修正实际时间,更早节点返回 A0409。") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", resourceId = "#queryId", description = "人工补录船节点事实") public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, 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 6e61b468b..7786ab362 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 @@ -205,6 +205,7 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + requireManualActualNotEarlier(value, node, request.actualAt()); CarrierScheduleQueryEntity before = copy(value); String providerEventId = sha256("MANUAL|" + id + '|' + key); CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node, @@ -214,7 +215,7 @@ public class CarrierScheduleQueryService { if (fact == null) { return complete(id, operatorId, key, detail(value)); } - boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + boolean advanced = advanceManualActualNode(value, node, request.actualAt(), request.observedAt(), operatorId, operatorName); if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) { throw concurrent(); @@ -226,6 +227,34 @@ public class CarrierScheduleQueryService { return complete(id, operatorId, key, detail(after)); } + private void requireManualActualNotEarlier(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt) { + if (actualAt == null || value.getLatestStandardNode() == null) return; + CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode()); + if (node.sequence() < current.sequence()) { + throw conflict("人工实际节点不得早于当前最新实际节点:" + current.name()); + } + } + + private boolean advanceManualActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt, LocalDateTime observedAt, + long operatorId, String operatorName) { + if (actualAt == null) return false; + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + if (current == node) { + value.setLatestNodeObservedAt(actualAt); value.setLastSuccessAt(now()); + value.setSourceUpdatedAt(observedAt); value.setLastAttemptStatus("SUCCESS"); + value.setLastErrorCode(null); value.setLastErrorMessage(null); + if (!"STOPPED".equals(value.getStatus())) { + value.setStatus("READY"); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now())); + } + if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); + return true; + } + return advanceActualNode(value, node, actualAt, observedAt, operatorId, operatorName); + } + private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, LocalDateTime actualAt, LocalDateTime observedAt, long operatorId, String operatorName) { diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 77b4052ea..0626f254d 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -82,7 +82,9 @@ paths: schema: {$ref: '#/components/schemas/ManualNodeRequest'} responses: '200': - description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops + description: Idempotent fact append; estimated facts never advance actual progress; the current latestActualNode may append an audited correction fact and replace latestActualAt; a later actual node advances progress and actual EMPTY_RETURNED stops + '409': + description: A0409 when expectedVersion is stale or a manual actual node is earlier than latestActualNode content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} components: parameters: @@ -122,7 +124,7 @@ components: ManualNodeRequest: type: object required: [expectedVersion, standardNode, observedAt, reason] - description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node. + description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode may equal latestActualNode to correct latestActualAt through a new immutable audit fact, but cannot be earlier. properties: expectedVersion: {type: integer, minimum: 0} standardNode: {$ref: '#/components/schemas/StandardNode'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 563955f74..cb9470c71 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -24,6 +24,9 @@ class CarrierScheduleQueryOpenApiContractTest { .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") .contains("latestActualNode:") .contains("latestActualAt:") + .contains("current latestActualNode may append an audited correction fact") + .contains("replace latestActualAt") + .contains("cannot be earlier") .contains("internal execution fields are intentionally absent") .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("/{queryId}/attempts:") 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 55d41c6f0..49386098b 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 @@ -292,6 +292,52 @@ class CarrierScheduleQueryServiceTest { verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); } + @Test + void manualActualMayCorrectCurrentNodeTimeThroughNewImmutableFact() { + CarrierScheduleQueryEntity before = task(0, 3); + before.setStatus("READY"); before.setLatestStandardNode("PICKED_UP"); + before.setLatestNodeObservedAt(NOW.minusDays(1)); + CarrierScheduleQueryEntity after = task(0, 4); + after.setStatus("READY"); after.setLatestStandardNode("PICKED_UP"); + after.setLatestNodeObservedAt(NOW.minusDays(2)); + when(repository.lock(1L)).thenReturn(before); when(repository.find(1L)).thenReturn(after); + when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-picked-up-correction", + new ManualNodeRequest(3, "PICKED_UP", NOW, null, NOW.minusDays(2), null, + "Los Angeles", "修正提柜时间")); + + assertThat(result.query().query().latestActualNode()).isEqualTo("PICKED_UP"); + assertThat(result.query().query().latestActualAt()).isEqualTo(NOW.minusDays(2)); + ArgumentCaptor fact = + ArgumentCaptor.forClass(CarrierScheduleNodeFactEntity.class); + verify(repository).insertIfAbsent(fact.capture()); + assertThat(fact.getValue().getActualAt()).isEqualTo(NOW.minusDays(2)); + verify(repository).updateState(before, 3, 7L, "admin"); + verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-picked-up-correction"), anyString()); + } + + @Test + void manualActualRejectsEarlierStandardNodeWithoutAppendingFact() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + task.setLatestNodeObservedAt(NOW.minusDays(1)); + when(repository.lock(1L)).thenReturn(task); + + assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late", + new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null, + "Los Angeles", "倒序补录可提柜"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不得早于当前最新实际节点"); + + verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class)); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class)); + verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString()); + verify(nodeOutbox, never()).publish(any(), any(), anyString()); + } + @Test void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() { CarrierScheduleQueryEntity before = task(0, 3);