feat: gate overseas receiving and harden phase4 workflows

This commit is contained in:
2026-08-25 10:31:04 +08:00
parent cb78bc6b41
commit 52b9fc382f
93 changed files with 2779 additions and 201 deletions
@@ -102,6 +102,25 @@ class OutboundDispatchServiceTest {
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
}
@Test
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules);
stubReadyChain();
OutboundDispatchEntity state = new OutboundDispatchEntity();
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1);
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked",
new ActionRequest(11L, 22L, 1, "误操作")))
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE");
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
}
@Test
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
@@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest {
.contains("outbound-dispatches/check-gate:")
.contains("outbound-dispatches/confirm-dispatch:")
.contains("outbound-dispatches/reverse-dispatch:")
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
.contains("expectedVersion")
.contains("Idempotency-Key");
}
@@ -103,6 +103,20 @@ class CarrierScheduleQuerySqlContractTest {
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
}
@Test
void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(unified)
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'")
.contains("uk_inbound_outbox_event_key")
.contains("MODIFY COLUMN payload_hash VARCHAR(71)")
.contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL");
}
private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -21,7 +22,8 @@ import static org.mockito.Mockito.verify;
class CarrierScheduleNodeOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
@BeforeEach
@@ -56,7 +58,7 @@ class CarrierScheduleNodeOutboxServiceTest {
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
service.publish(query(), fact, "ZCSU7881970");
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any());
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
private static CarrierScheduleQueryEntity query() {
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
class CarrierScheduleQuerySchedulerTest {
@Test
@@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest {
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
@Test
void leaseLoserNeverCallsProviderOrWritesAttempt() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
CarrierScheduleQueryEntity task = task(1L);
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false);
scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0));
verify(integration, never()).query(any(), any());
verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any());
}
@Test
void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM");
task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970");
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0);
var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY",
now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64));
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(),
"HTTP_503", "unavailable", now)));
scheduler.execute(task, now);
verify(service, never()).applyProviderObservation(anyLong(), any());
verify(service, never()).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable");
}
private static CarrierScheduleQueryEntity task(long id) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
}
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExc
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -38,11 +39,13 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
class CarrierScheduleQueryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
@Mock private CarrierScheduleQueryRepository repository;
@Mock private PortScheduleRepository portSchedules;
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
@InjectMocks private CarrierScheduleQueryService service;
@BeforeEach
@@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest {
when(repository.nodes(anyLong())).thenReturn(List.of());
when(repository.exceptions(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.containerBooking(20L)).thenReturn(container);
}
@Test
@@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(task.getNextAllowedAt()).isNull();
}
@Test
void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() {
CarrierScheduleQueryEntity task = task(0, 4);
task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED");
task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge",
"DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2),
"USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64)));
assertThat(task.getStatus()).isEqualTo("STOPPED");
assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(task.getNextAllowedAt()).isNull();
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
}
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
@@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest {
assertThat(task.getLatestStandardNode()).isNull();
}
@Test
void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() {
CarrierScheduleQueryEntity task = task(0, 1);
task.setLatestStandardNode("IN_TRANSIT");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) {
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON",
code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64)));
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF",
code + " RELEASED", code, false, NOW.plusMinutes(1), null, null,
"provider:" + code + ":released", "b".repeat(64)));
}
verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT");
}
@Test
void providerEventIdReplayRequiresTheSameRawPayloadHash() {
CarrierScheduleQueryEntity task = task(0, 1);
task.setLatestStandardNode("IN_TRANSIT");
CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity();
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);
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
service.applyProviderObservation(1L, replay);
verify(nodeOutbox, never()).publish(any(), any(), anyString());
ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64));
assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict))
.isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash");
}
@Test
void everySchedulerCompletionWritesImmutableAttempt() {
CarrierScheduleQueryEntity task = task(0, 0);