fix(firstleg): enforce strict manual node progression
This commit is contained in:
+1
-1
@@ -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,
|
||||
|
||||
+6
-25
@@ -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'}
|
||||
|
||||
+2
-3
@@ -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:")
|
||||
|
||||
+10
-28
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user