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")
|
@PostMapping("/{queryId}/manual-nodes")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||||
@Operation(summary = "人工补录预计或实际标准船节点",
|
@Operation(summary = "人工补录预计或实际标准船节点",
|
||||||
description = "预计节点不推进实际进度;同级当前节点可追加审计事实并修正实际时间,更早节点返回 A0409。")
|
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
|
||||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
|
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||||
resourceId = "#queryId", description = "人工补录船节点事实")
|
resourceId = "#queryId", description = "人工补录船节点事实")
|
||||||
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
|||||||
+6
-25
@@ -205,7 +205,7 @@ public class CarrierScheduleQueryService {
|
|||||||
}
|
}
|
||||||
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
|
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
|
||||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||||
requireManualActualNotEarlier(value, node, request.actualAt());
|
requireManualActualAdvance(value, node, request.actualAt());
|
||||||
CarrierScheduleQueryEntity before = copy(value);
|
CarrierScheduleQueryEntity before = copy(value);
|
||||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||||
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
||||||
@@ -215,7 +215,7 @@ public class CarrierScheduleQueryService {
|
|||||||
if (fact == null) {
|
if (fact == null) {
|
||||||
return complete(id, operatorId, key, detail(value));
|
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);
|
operatorId, operatorName);
|
||||||
if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) {
|
if (!advanced && repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) {
|
||||||
throw concurrent();
|
throw concurrent();
|
||||||
@@ -227,34 +227,15 @@ public class CarrierScheduleQueryService {
|
|||||||
return complete(id, operatorId, key, detail(after));
|
return complete(id, operatorId, key, detail(after));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireManualActualNotEarlier(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||||
LocalDateTime actualAt) {
|
LocalDateTime actualAt) {
|
||||||
if (actualAt == null || value.getLatestStandardNode() == null) return;
|
if (actualAt == null || value.getLatestStandardNode() == null) return;
|
||||||
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
|
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
|
||||||
if (node.sequence() < current.sequence()) {
|
if (node.sequence() <= current.sequence()) {
|
||||||
throw conflict("人工实际节点不得早于当前最新实际节点:" + current.name());
|
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,
|
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||||
LocalDateTime actualAt, LocalDateTime observedAt,
|
LocalDateTime actualAt, LocalDateTime observedAt,
|
||||||
long operatorId, String operatorName) {
|
long operatorId, String operatorName) {
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ paths:
|
|||||||
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
|
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'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':
|
'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'}}}
|
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -124,7 +124,7 @@ components:
|
|||||||
ManualNodeRequest:
|
ManualNodeRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [expectedVersion, standardNode, observedAt, reason]
|
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:
|
properties:
|
||||||
expectedVersion: {type: integer, minimum: 0}
|
expectedVersion: {type: integer, minimum: 0}
|
||||||
standardNode: {$ref: '#/components/schemas/StandardNode'}
|
standardNode: {$ref: '#/components/schemas/StandardNode'}
|
||||||
|
|||||||
+2
-3
@@ -24,9 +24,8 @@ class CarrierScheduleQueryOpenApiContractTest {
|
|||||||
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
|
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
|
||||||
.contains("latestActualNode:")
|
.contains("latestActualNode:")
|
||||||
.contains("latestActualAt:")
|
.contains("latestActualAt:")
|
||||||
.contains("current latestActualNode may append an audited correction fact")
|
.contains("manual actual node is equal to or earlier than latestActualNode")
|
||||||
.contains("replace latestActualAt")
|
.contains("strictly greater sequence than latestActualNode")
|
||||||
.contains("cannot be earlier")
|
|
||||||
.contains("internal execution fields are intentionally absent")
|
.contains("internal execution fields are intentionally absent")
|
||||||
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
|
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
|
||||||
.doesNotContain("/{queryId}/attempts:")
|
.doesNotContain("/{queryId}/attempts:")
|
||||||
|
|||||||
+10
-28
@@ -293,43 +293,25 @@ class CarrierScheduleQueryServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void manualActualMayCorrectCurrentNodeTimeThroughNewImmutableFact() {
|
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
|
||||||
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);
|
CarrierScheduleQueryEntity task = task(0, 3);
|
||||||
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
|
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
|
||||||
task.setLatestNodeObservedAt(NOW.minusDays(1));
|
task.setLatestNodeObservedAt(NOW.minusDays(1));
|
||||||
when(repository.lock(1L)).thenReturn(task);
|
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",
|
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late",
|
||||||
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
|
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
|
||||||
"Los Angeles", "倒序补录可提柜")))
|
"Los Angeles", "倒序补录可提柜")))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不得早于当前最新实际节点");
|
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||||
|
|
||||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||||
|
|||||||
Reference in New Issue
Block a user