This commit is contained in:
2026-08-21 16:04:16 +08:00
parent dceec6cbed
commit 0e7df9052a
68 changed files with 3985 additions and 267 deletions
@@ -15,10 +15,17 @@ class CarrierScheduleQueryOpenApiContractTest {
assertThat(yaml)
.contains("/api/platform/v1/carrier-schedule-queries:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
.contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY")
.contains("MANUAL_REQUIRED")
.contains("scheduler leases and raw attempts are never exposed")
.contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT")
.contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED")
.contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD")
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
.contains("latestActualNode:")
.contains("latestActualAt:")
.contains("internal execution fields are intentionally absent")
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
.doesNotContain("/{queryId}/attempts:")
.doesNotContain("port-schedule-query:attempt");
}
@@ -22,13 +22,14 @@ class PlatformCarrierScheduleQueryControllerContractTest {
"detail", "platform:firstleg:port-schedule-query:list",
"history", "platform:firstleg:port-schedule-query:list",
"create", "platform:firstleg:port-schedule-query:create",
"manualResult", "platform:firstleg:port-schedule-query:manual");
"manualResult", "platform:firstleg:port-schedule-query:manual",
"manualNode", "platform:firstleg:port-schedule-query:manual");
permissions.forEach((name, permission) -> {
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (name.equals("create") || name.equals("manualResult")) {
if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.portschedule.dto;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQueryDtosTest {
@Test
void omittedPageAndSizeBindToDocumentedDefaults() {
var query = new CarrierScheduleQueryDtos.Query(null, null, null, null, null, null, null, null, null);
assertThat(query.page()).isEqualTo(1L);
assertThat(query.size()).isEqualTo(20L);
}
}
@@ -18,15 +18,25 @@ class CarrierScheduleQuerySqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact")
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact")
.contains("attempt_count BETWEEN 0 AND 3")
.contains("window_attempt_no BETWEEN 1 AND 3")
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
.contains("船司查询尝试不可修改")
.contains("船司查询历史不可删除")
.contains("标准船节点事实不可修改")
.contains("ARRIVED','PICKUP_READY','STOPPED")
.contains("船节点异常事实不可修改")
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
.contains("LOADED_ON_VESSEL")
.contains("ARRIVED_DESTINATION_PORT")
.contains("DISCHARGED_FROM_VESSEL")
.contains("PICKED_UP','EMPTY_RETURNED")
.contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD")
.contains("provider_event_id")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("lease_owner")
.contains("consecutive_failures");
.contains("consecutive_failures")
.doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'");
}
@Test
@@ -36,7 +46,8 @@ class CarrierScheduleQuerySqlContractTest {
.contains("allocation.container_booking_id=NEW.container_booking_id")
.contains("id IN (5102184,5102186,5102188)")
.contains("platform:firstleg:port-schedule-query:manual")
.contains("'platform:firstleg:port-schedule-query:attempt'")
.contains("permission.permission_code IN ('platform:firstleg:port-schedule-query:view'")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("船司查询任务角色授权未完整创建");
}
@@ -50,7 +61,9 @@ class CarrierScheduleQuerySqlContractTest {
.contains("uk_carrier_schedule_query_scope")
.contains("id IN (5102184,5102186,5102188)")
.contains("assert_carrier_schedule_query_schema")
.contains("船司查询任务角色授权未完整创建");
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("船司查询任务角色授权未完整创建")
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
}
private static String moduleSql() throws Exception {
@@ -20,16 +20,18 @@ class CarrierScheduleAdapterRegistryTest {
}
@Test
void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() {
void returnsStructuredManualRequirementsForCaptchaAndPendingOfficialApiCredentials() {
var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053");
var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123");
assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("HCAPTCHA");
assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053");
assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME");
assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED");
assertThat(zim.accessMode()).isEqualTo("OFFICIAL_API");
assertThat(zim.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED");
assertThat(zim.officialUrl()).isEqualTo("https://api.zim.com/");
assertThat(cosco.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(cosco.accessMode()).isEqualTo("OFFICIAL_API");
assertThat(cosco.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED");
assertThat(cosco.officialUrl()).isEqualTo("https://cop.lines.coscoshipping.com/copPortal/");
}
@Test
@@ -26,16 +26,20 @@ class CarrierScheduleQuerySchedulerTest {
task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567");
LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0);
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED",
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "DISCHARGED_FROM_VESSEL",
now, null, now, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
var exception = new CarrierScheduleIntegrationClient.ExceptionObservation("hold-1", "CUSTOMS HOLD",
"CUSTOMS_HOLD", true, now, "USLAX", "Los Angeles", "provider:hold-1",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation),
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), List.of(exception),
null, null, now)));
scheduler.execute(task, now);
verify(service).applyProviderObservation(anyLong(), any());
verify(service).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
}
@@ -5,8 +5,12 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEn
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
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.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -15,7 +19,9 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
@@ -28,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,6 +54,7 @@ class CarrierScheduleQueryServiceTest {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
when(repository.nodes(anyLong())).thenReturn(List.of());
when(repository.exceptions(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
}
@@ -86,7 +94,7 @@ class CarrierScheduleQueryServiceTest {
}
@Test
void providerObservationAdvancesToArrivedWithoutExposingAttempts() {
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setLatestStandardNode("IN_TRANSIT");
when(repository.lock(1L)).thenReturn(task);
@@ -96,14 +104,172 @@ class CarrierScheduleQueryServiceTest {
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1",
"DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"DISCHARGED", "ARRIVED_DESTINATION_PORT", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assertThat(result.query().query().status()).isEqualTo("ARRIVED");
assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED");
assertThat(result.query().query().status()).isEqualTo("READY");
assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT");
assertThat(result.nodes()).isEmpty();
}
@Test
void estimatedObservationIsSavedWithoutAdvancingActualNode() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY"); task.setLatestStandardNode("LOADED_ON_VESSEL");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "eta-1", "PRE_ARRIVAL",
"ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(10), null, null, null, "HEDE:X",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
assertThat(task.getLatestStandardNode()).isEqualTo("LOADED_ON_VESSEL");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void lateActualFactIsSavedButCannotRegressLatestNode() {
CarrierScheduleQueryEntity task = task(0, 5);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "late-1", "DISCHARGED",
"DISCHARGED_FROM_VESSEL", NOW, null, NOW.minusDays(2), null, null, "HEDE:X",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"));
assertThat(task.getLatestStandardNode()).isEqualTo("PICKED_UP");
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
}
@Test
void onlyActualEmptyReturnedStopsTask() {
CarrierScheduleQueryEntity task = task(0, 2);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "return-1", "EMPTY_RETURNED",
"EMPTY_RETURNED", NOW, null, NOW, null, null, "HEDE:X",
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"));
assertThat(task.getStatus()).isEqualTo("STOPPED");
assertThat(task.getStopReason()).isEqualTo("已还柜");
assertThat(task.getNextAllowedAt()).isNull();
}
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
.thenReturn(true);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
var result = service.addManualNode(1L, 7L, "admin", "manual-empty-1",
new ManualNodeRequest(3, "EMPTY_RETURNED", NOW, null, NOW, null, "Los Angeles", "人工确认还柜"));
assertThat(result.query().query().status()).isEqualTo("STOPPED");
assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED");
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
}
@Test
void holdIsPersistedAsSeparateImmutableExceptionFact() {
CarrierScheduleQueryEntity task = task(0, 1);
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
service.applyProviderException(1L, new ProviderExceptionRequest("MATSON_PUBLIC_TRACKING", "hold-1",
"CONTAINER ON HOLD - Customs Clearance", "CUSTOMS_HOLD", true, NOW, "USLAX",
"Long Beach", "MATSON:9974749",
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"));
ArgumentCaptor<CarrierScheduleExceptionFactEntity> fact =
ArgumentCaptor.forClass(CarrierScheduleExceptionFactEntity.class);
verify(repository).insertIfAbsent(fact.capture());
assertThat(fact.getValue().getExceptionCode()).isEqualTo("CUSTOMS_HOLD");
assertThat(fact.getValue().getActive()).isTrue();
assertThat(task.getLatestStandardNode()).isNull();
}
@Test
void everySchedulerCompletionWritesImmutableAttempt() {
CarrierScheduleQueryEntity task = task(0, 0);
task.setStatus("READY");
when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task);
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
service.completeScheduledAttempt(1L, "NO_CHANGE", null, null);
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
verify(repository).insert(attempt.capture());
assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE");
assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler");
assertThat(task.getStatus()).isEqualTo("READY");
}
@Test
void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() {
CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry();
assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isFalse();
assertThat(value.coverageGap()).isTrue();
});
ReflectionTestUtils.setField(registry, "matsonAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_WEB_PROXY");
});
ReflectionTestUtils.setField(registry, "hedeAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "HEDE".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_HTML");
});
ReflectionTestUtils.setField(registry, "smlAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "SML".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM");
});
ReflectionTestUtils.setField(registry, "zimOfficialApiEnabled", true);
assertThat(registry.descriptors()).filteredOn(value -> "ZIM".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("OFFICIAL_API");
});
ReflectionTestUtils.setField(registry, "emcAutomated", true);
assertThat(registry.descriptors()).filteredOn(value -> "EMC".equals(value.carrierCode()))
.singleElement().satisfies(value -> {
assertThat(value.automated()).isTrue();
assertThat(value.coverageGap()).isFalse();
assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM");
});
}
@Test
void manualResultMustBelongToSameCustomerBookingAndContainer() {
CarrierScheduleQueryEntity task = task(1, 1);
@@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151);
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")