fix(firstleg): enforce strict manual node progression

This commit is contained in:
2026-08-26 15:44:05 +08:00
parent 2ebea9e789
commit 57a2f6ef28
5 changed files with 22 additions and 60 deletions
@@ -98,7 +98,7 @@ public class PlatformCarrierScheduleQueryController {
@PostMapping("/{queryId}/manual-nodes")
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
@Operation(summary = "人工补录预计或实际标准船节点",
description = "预计节点不推进实际进度;同级当前节点可追加审计事实并修正实际时间,更早节点返回 A0409。")
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
resourceId = "#queryId", description = "人工补录船节点事实")
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
@@ -205,7 +205,7 @@ public class CarrierScheduleQueryService {
}
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualNotEarlier(value, node, request.actualAt());
requireManualActualAdvance(value, node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
@@ -215,7 +215,7 @@ public class CarrierScheduleQueryService {
if (fact == null) {
return complete(id, operatorId, key, detail(value));
}
boolean advanced = advanceManualActualNode(value, node, request.actualAt(), request.observedAt(),
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
operatorId, operatorName);
if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) {
throw concurrent();
@@ -227,34 +227,15 @@ public class CarrierScheduleQueryService {
return complete(id, operatorId, key, detail(after));
}
private void requireManualActualNotEarlier(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
LocalDateTime actualAt) {
private void requireManualActualAdvance(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());
if (node.sequence() <= current.sequence()) {
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
}
}
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,9 +82,9 @@ paths:
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
responses:
'200':
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
description: Idempotent fact append; estimated facts never advance actual progress, while a manual actual fact must have a strictly greater standard-node sequence than latestActualNode and actual EMPTY_RETURNED stops
'409':
description: A0409 when expectedVersion is stale or a manual actual node is earlier than latestActualNode
description: A0409 when expectedVersion is stale or a manual actual node is equal to or earlier than latestActualNode
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
components:
parameters:
@@ -124,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; when actualAt is present, standardNode may equal latestActualNode to correct latestActualAt through a new immutable audit fact, but cannot be earlier.
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode must have a strictly greater sequence than latestActualNode.
properties:
expectedVersion: {type: integer, minimum: 0}
standardNode: {$ref: '#/components/schemas/StandardNode'}
@@ -24,9 +24,8 @@ 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("manual actual node is equal to or earlier than latestActualNode")
.contains("strictly greater sequence than latestActualNode")
.contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("/{queryId}/attempts:")
@@ -293,43 +293,25 @@ class CarrierScheduleQueryServiceTest {
}
@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() {
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
task.setLatestNodeObservedAt(NOW.minusDays(1));
when(repository.lock(1L)).thenReturn(task);
ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null,
NOW.plusDays(1), null, "Los Angeles", "重复确认提柜");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
.isInstanceOf(BizException.class)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
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("不得早于当前最新实际节点");
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());