fix(firstleg): allow audited carrier node time corrections

This commit is contained in:
2026-08-26 15:41:03 +08:00
parent 4ae236fe11
commit 2ebea9e789
5 changed files with 85 additions and 3 deletions
@@ -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<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@@ -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) {
@@ -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'}
@@ -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:")
@@ -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<CarrierScheduleNodeFactEntity> 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);