This commit is contained in:
2026-08-26 14:48:43 +08:00
parent 0f4524c795
commit bf960f10d1
22 changed files with 237 additions and 40 deletions
@@ -86,6 +86,16 @@ class PlatformOceanBookingControllerContractTest {
.contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking.");
}
@Test
void openApiFreezesStandardCarrierCodeOnBooking() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml)
.contains("carrierCode:")
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]")
.contains("Required before RELEASE")
.contains("OTHER means manual carrier-node tracking");
}
private static Method method(String name) {
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
@@ -24,6 +24,8 @@ class OceanBookingSqlContractTest {
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
@@ -259,12 +259,12 @@ class OceanBookingServiceTest {
}
private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null,
return new DraftData(null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
}
private static DraftData completeDraft() {
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司",
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON",
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null,
@@ -279,6 +279,7 @@ class OceanBookingServiceTest {
OceanBookingEntity value = booking(status, version);
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setCarrierCode("MATSON");
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
@@ -63,6 +63,8 @@ class CarrierScheduleQuerySqlContractTest {
.contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'")
.contains("provider_event_id")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'")
.contains("'OFFICIAL_API','MANUAL'")
.contains("lease_owner")
.contains("consecutive_failures")
.doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'");
@@ -102,11 +104,14 @@ class CarrierScheduleQuerySqlContractTest {
.contains("ADD COLUMN next_allowed_at DATETIME(3) NULL")
.contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)")
.contains("DROP CHECK chk_carrier_schedule_query_access")
.contains("DROP CHECK chk_carrier_schedule_query_carrier")
.contains("DROP CHECK chk_carrier_schedule_query_status")
.contains("DROP INDEX uk_carrier_schedule_query_scope")
.contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope")
.contains("(customer_id,ocean_booking_id,container_scope_id)")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'")
.contains("'OFFICIAL_API','MANUAL'")
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
.contains("ARRIVED_DESTINATION_PORT")
.contains("EMPTY_RETURNED")
@@ -39,4 +39,16 @@ class CarrierScheduleAdapterRegistryTest {
assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X"))
.hasMessageContaining("仅支持MATSON");
}
@Test
void outboundUnknownCarrierFallsBackToManualWithoutPretendingToBeAnotherProvider() {
var result = registry.prepareOutbound("OTHER", "CONTAINER_NO", "CSNU1234567");
assertThat(result.carrierCode()).isEqualTo("OTHER");
assertThat(result.accessMode()).isEqualTo("MANUAL");
assertThat(result.status()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.coverageGapCode()).isEqualTo("UNSUPPORTED_CARRIER");
assertThat(result.officialUrl()).isEmpty();
assertThat(registry.prepareOutbound(null, "BOOKING_NO", "OB-1").carrierCode()).isEqualTo("OTHER");
}
}
@@ -47,12 +47,14 @@ class CarrierScheduleQueryServiceTest {
@Mock private PortScheduleRepository portSchedules;
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
@InjectMocks private CarrierScheduleQueryService service;
private CarrierScheduleAdapterRegistry adapters;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
set("objectMapper", new ObjectMapper().findAndRegisterModules());
set("adapters", new CarrierScheduleAdapterRegistry());
adapters = new CarrierScheduleAdapterRegistry();
set("adapters", adapters);
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
@@ -85,7 +87,7 @@ class CarrierScheduleQueryServiceTest {
@Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001");
booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
@@ -96,12 +98,74 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.getAccessMode()).isEqualTo("OFFICIAL_API");
assertThat(result.getManualReason()).contains("Cargo Tracking API");
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
}
@Test
void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
assertThat(result.getCarrierCode()).isEqualTo("OTHER");
assertThat(result.getAccessMode()).isEqualTo("MANUAL");
assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED");
assertThat(result.getManualReason()).contains("carrierCode=OTHER", "shippingCompanyId=9988", "shippingCompanyName=示例船公司");
assertThat(result.getNextAllowedAt()).isNull();
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
}
@Test
void outboundSupportedCarrierCreatesReadyTask() {
ReflectionTestUtils.setField(adapters, "matsonAutomated", true);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("美森"); booking.setCarrierCode("MATSON");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("MATU4646247");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
assertThat(result.getCarrierCode()).isEqualTo("MATSON");
assertThat(result.getStatus()).isEqualTo("READY");
assertThat(result.getAccessMode()).isEqualTo("PUBLIC_WEB_PROXY");
assertThat(result.getManualReason()).isNull();
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
}
@Test
void outboundReplayKeepsSingleTaskAndSingleDepartureFact() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first);
CarrierScheduleQueryEntity replay = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
assertThat(replay).isSameAs(first);
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class));
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
}
@Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0);