fix(firstleg): decouple manual container nodes from tracking

This commit is contained in:
2026-09-10 12:17:45 +08:00
parent c1a36c6f40
commit 443b01c8a8
21 changed files with 285 additions and 147 deletions
@@ -28,8 +28,6 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("Estimated facts are never returned or inferred")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
.contains("/api/platform/v1/carrier-schedule-queries/containers/{containerBookingId}/ensure:")
.contains("platform-owned consolidated bookings use customerId 0")
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
@@ -40,8 +38,6 @@ class CarrierScheduleQueryOpenApiContractTest {
.contains("cabinetNo, orderNo, bookingNo and bill of lading are never fallbacks")
.contains("latestActualNode:")
.contains("latestActualAt:")
.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("enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]")
@@ -23,7 +23,6 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list",
"create", "platform:firstleg:port-schedule-query:create",
"ensureForManualEntry", "platform:firstleg:port-schedule-query:manual",
"manualResult", "platform:firstleg:port-schedule-query:manual",
"manualNode", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
@@ -31,7 +30,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("ensureForManualEntry") || name.equals("manualResult") || name.equals("manualNode")) {
if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -451,34 +451,6 @@ class CarrierScheduleQueryServiceTest {
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
}
@Test
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)
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
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);
@@ -549,7 +521,7 @@ class CarrierScheduleQueryServiceTest {
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing);
when(repository.nodeByContainer(task.getContainerBookingId(), "ZIM:evt-duplicate")).thenReturn(existing);
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import org.junit.jupiter.api.Test;
import org.apache.ibatis.annotations.Select;
import org.springframework.security.access.prepost.PreAuthorize;
import java.nio.file.Files;
@@ -50,10 +51,24 @@ class TerminalDrayageOpenApiContractTest {
"platform:firstleg:terminal-drayage:pickup:confirm",
"platform:firstleg:terminal-drayage:empty-return:confirm",
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays",
"船节点跟踪任务尚未建立也返回WAITING_FOR_ARRIVAL空详情",
"允许读取空邮件列表,不以船节点跟踪任务是否已经建立作为货柜存在性条件");
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
}
@Test
void terminalDrayageContextsUseConfirmedOutboundAsTheOverseasBoundary() throws Exception {
var mapper = com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class;
for (String methodName : java.util.List.of("context", "lockContext", "contexts")) {
var method = java.util.Arrays.stream(mapper.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
String sql = String.join("\n", method.getAnnotation(Select.class).value());
assertThat(sql).contains("FROM fl_outbound_dispatch", "d.confirmed_at IS NOT NULL",
"d.reversed_at IS NULL", "d.status='WAITING_FOR_LOADING'");
}
}
private static void assertPermission(String methodName, String permission) {
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
@@ -92,19 +92,27 @@ class ContainerWorkbenchServiceTest {
"platform:firstleg:terminal-drayage:document:edit");
when(repository.find(11L)).thenReturn(container);
when(repository.departed(11L)).thenReturn(true);
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of());
when(terminalDrayages.authorize(List.of(), permissions)).thenReturn(List.of());
var waiting = waitingDrayage();
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(waiting));
when(terminalDrayages.authorize(List.of(waiting), permissions)).thenReturn(List.of(waiting));
var result = service.overseasDetail(11L, permissions);
assertThat(result.workbenchStage()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.actualFacts()).isNull();
assertThat(result.terminalDrayage()).isNull();
assertThat(result.terminalDrayage()).isEqualTo(waiting);
assertThat(result.allowedActions()).containsExactly("MANAGE_DESTINATION_DOCUMENTS");
verify(terminalDrayages, never()).detail(11L);
verifyNoInteractions(carrierSchedules);
}
private static com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View waitingDrayage() {
return new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View(
11L, "MSCU1234567", 21L, "WAITING_FOR_ARRIVAL", 0, null, null, null,
null, null, null, null, new com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning(
"NOT_APPLICABLE", null, 0, 0), List.of(), null, null);
}
@Test
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
verifyRealEmptyReturnProjection(true, true);
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
@@ -125,8 +125,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("PICKED_UP");
assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0));
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP");
verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString());
}
@@ -144,7 +144,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜");
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
@@ -212,7 +212,7 @@ class TerminalDrayageServiceTest {
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
assertThat(service.recordEmptyReturn(11L, 7L, "admin", "return-new", request)).isEqualTo(result);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(carrierSchedules, times(1)).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
verify(carrierSchedules, times(1)).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
@@ -222,7 +222,7 @@ class TerminalDrayageServiceTest {
new ActualTimeRequest(0, LocalDateTime.of(2026, 9, 2, 23, 0), null)))
.isInstanceOf(BizException.class).hasMessage("实际还柜时间不能早于提柜或卸柜完成时间");
verify(mapper, never()).insert(any(TerminalDrayageEntity.class));
verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
@@ -243,13 +243,26 @@ class TerminalDrayageServiceTest {
}
@Test
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
void readsDepartedContainerBeforeCarrierTrackingRecordExists() {
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
when(mapper.context(11L)).thenReturn(
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null));
when(mapper.find(11L)).thenReturn(null);
assertThat(service.views(List.of(11L))).isEmpty();
verify(carrierSchedules).containerActualFacts(List.of(11L));
var bulk = service.views(List.of(11L));
var detail = service.detail(11L);
assertThat(bulk).singleElement().satisfies(view -> {
assertThat(view.status()).isEqualTo("WAITING_FOR_ARRIVAL");
assertThat(view.actualFacts()).isNull();
assertThat(view.allowedActions()).isEmpty();
});
assertThat(detail.status()).isEqualTo("WAITING_FOR_ARRIVAL");
assertThat(detail.actualFacts()).isNull();
assertThat(detail.allowedActions()).isEmpty();
verify(carrierSchedules, times(2)).containerActualFacts(List.of(11L));
}
@Test
@@ -338,8 +351,8 @@ class TerminalDrayageServiceTest {
assertThat(result.status()).isEqualTo("EMPTY_RETURNED");
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72));
assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED");
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
ArgumentCaptor<ContainerManualNodeRequest> node = ArgumentCaptor.forClass(ContainerManualNodeRequest.class);
verify(carrierSchedules).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(node.getValue().reason()).isEqualTo("已还柜");
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));