feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSourceMapperTest {
@Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)");
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)");
s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')");
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)");
s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2)");
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(AppointmentWorkSourceMapper.class).sources();
assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id());
assertNull(rows.get(0).destinationWarehouseName());assertEquals(1,rows.get(1).deliveryVersion());
assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt());
}
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.portschedule.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class PickupActualCalendarMapperTest {
@Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')");
}
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper=session.getMapper(CarrierScheduleQueryMapper.class);
var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00");
assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to));
try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) {
for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch();
}
session.clearCache();
assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size());
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
import com.baiyue.firstleg.portschedule.controller.InternalAppointmentWorkSourceController;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSourceServiceTest {
private final AppointmentWorkSourceMapper mapper=mock(AppointmentWorkSourceMapper.class);
private final AppointmentWorkSourceService.OriginalFacts facts=mock(AppointmentWorkSourceService.OriginalFacts.class);
private final AppointmentWorkSourceService service=new AppointmentWorkSourceService(mapper,facts,new ObjectMapper().findAndRegisterModules());
private AppointmentWorkSourceMapper.Source source(long id,int version) {
return new AppointmentWorkSourceMapper.Source(id,"D000"+id,null,"上海",null,LocalDateTime.parse("2026-10-01T12:00:00"),LocalDateTime.parse("2026-10-01T12:00:00"),version,10L,0L);
}
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1)));
when(mapper.cargo(anyLong())).thenReturn(List.of()); when(facts.establishedFlow(anyLong())).thenReturn("UNKNOWN");
var first=service.discover(null,1);
assertFalse(first.complete()); assertNotNull(first.nextCursor());
assertNull(first.containers().get(0).departedAt());
assertEquals(LocalDateTime.parse("2026-10-01T12:00:00"),first.containers().get(0).departedLocalAt());
var next=service.discover(first.nextCursor(),1);
assertTrue(next.complete());assertEquals("2",next.containers().get(0).containerBookingId());
when(mapper.sources()).thenReturn(List.of(source(1,2),source(2,1)));
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.discover(first.nextCursor(),1)).getStatusCode().value());
}
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
when(mapper.sources()).thenReturn(List.of(source(1,1)));
when(mapper.cargo(1)).thenReturn(List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)));
when(facts.establishedFlow(1)).thenReturn("DIRECT");
var source=service.container(1);var requirement=source.requirements().get(0);
assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind());
assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null));
}
@Test void internalControllerFailsClosedBeforeReadingSources() {
var guarded=new InternalAppointmentWorkSourceController(service,"secret");
assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value());
verifyNoInteractions(mapper);
}
}
@@ -53,6 +53,7 @@ class CarrierScheduleQueryServiceTest {
@Mock private CarrierScheduleQueryRepository repository;
@Mock private PortScheduleRepository portSchedules;
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
@Mock private EmptyReturnPrerequisite emptyReturnPrerequisite;
@InjectMocks private CarrierScheduleQueryService service;
private CarrierScheduleAdapterRegistry adapters;
@@ -84,6 +85,19 @@ class CarrierScheduleQueryServiceTest {
verify(repository).containerActualFacts(List.of(20L, 21L));
}
@Test
void containerManualEmptyReturnCannotBypassRealUnloadingEvidence() {
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"未卸货"))
.when(emptyReturnPrerequisite).requireUnloaded(20L,NOW);
assertThatThrownBy(()->service.addContainerManualNode(20L,7L,"admin","empty-no-unload",
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
"EMPTY_RETURNED",NOW,null,NOW,null,null,"还柜")))
.isInstanceOf(BizException.class).hasMessage("未卸货");
verifyNoNodePublication();
}
private void verifyNoNodePublication() { org.mockito.Mockito.verifyNoInteractions(nodeOutbox); }
@Test
void rejectsInvalidActualFactBatchBeforeRepositoryAccess() {
assertThatThrownBy(() -> service.containerActualFacts(null))
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
int expectedTables = 226;
int expectedTables = 227;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables);
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class DirectDeliveryMapperTest {
@Test void physicalSourceAggregationAndImmutableEvidenceSurviveActualPersistence() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR,unloading_completed_at TIMESTAMP)");
s.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR)");
s.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,order_no VARCHAR,destination_warehouse_name VARCHAR,package_count INT,deleted INT)");
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))");
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')");
s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)");
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id());
assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount());
var at=LocalDateTime.parse("2026-01-01T10:30:00");
mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin");
assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key"));
assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt());
assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin"));
session.rollback();
}
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.junit.jupiter.api.Test;
import java.nio.file.*;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class DirectDeliverySchemaContractTest {
@Test void immutableDirectEvidenceAndIndependentWritePermissionsReachDeploymentBaseline() throws Exception {
String source=Files.readString(Path.of("src/main/resources/db/phase3-direct-delivery-schema.sql"));
String release=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertEquals(definition(source),definition(release));
assertTrue(source.contains("PRIMARY KEY (container_booking_id,inbound_id)"));
assertTrue(source.contains("UNIQUE KEY uk_direct_unloading_command"));
String permissions=Files.readString(Path.of("../iam/src/main/resources/db/appointment-work-permissions.sql"));
for(String code:java.util.List.of("platform:appointment:manage-work","platform:firstleg:terminal-drayage:direct-unload:confirm")) {
assertTrue(release.contains("'"+code+"'"));assertTrue(permissions.contains("'"+code+"'"));
}
assertTrue(release.contains("VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3))"));
assertTrue(release.contains("VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3))"));
assertTrue(permissions.indexOf("CALL check_appointment_work_permission_identity")<permissions.indexOf("INSERT INTO iam_permission"));
assertTrue(permissions.contains("platform:firstleg:terminal-drayage:direct-unload:confirm"));
assertTrue(permissions.contains("platform:appointment:manage-work"));
}
private String definition(String sql) {
var match=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? fl_direct_delivery_unloading \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertTrue(match.find());String result=match.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
assertFalse(match.find());return result;
}
}
@@ -29,6 +29,15 @@ class TerminalDrayageCalendarMapperTest {
var rows=session.getMapper(TerminalDrayageMapper.class).calendar(LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-11-01T00:00:00"),1001);
assertEquals(List.of(1L,2L),rows.stream().map(r->r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt());
assertEquals("D0001", rows.get(0).cabinetNo());
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("UPDATE fl_terminal_drayage SET pickup_appointment_at='2026-12-01' WHERE container_booking_id=3");
s.execute("DELETE FROM fl_terminal_drayage WHERE container_booking_id=2");
}
session.clearCache();
rows=session.getMapper(TerminalDrayageMapper.class).calendarByContainers(List.of(1L,2L,3L,4L,5L));
assertEquals(List.of(1L,2L,3L),rows.stream().map(r->r.containerBookingId()).toList());
assertNull(rows.get(1).pickupAppointmentAt());
assertEquals(LocalDateTime.parse("2026-12-01T00:00:00"),rows.get(2).pickupAppointmentAt());
}
}
}
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.terminaldrayage.mapper.*;
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkTerminalFactsTest {
private final TerminalDrayageMapper terminal=mock(TerminalDrayageMapper.class);
private final DirectDeliveryMapper direct=mock(DirectDeliveryMapper.class);
private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class);
private final UnloadingEvidenceGuard guard=mock(UnloadingEvidenceGuard.class);
private final AppointmentWorkTerminalFacts adapter=new AppointmentWorkTerminalFacts(terminal,direct,nodes,guard,new ObjectMapper().findAndRegisterModules());
private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00");
private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());}
private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());}
private History history(String action){return new History(action,"A","B",1,2,null,7L,"admin",at);}
@Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() {
var plan=new TerminalDrayageEntity();plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
when(terminal.find(1)).thenReturn(plan);
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP")));
var fact=adapter.read(request("PICKUP"),source());
assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date());
assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE"));
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP"),history("SCHEDULE_PICKUP")));
assertEquals("1:0",adapter.read(request("PICKUP"),source()).impactToken());
}
@Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() {
var plan=new TerminalDrayageEntity();plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
when(terminal.find(1)).thenReturn(plan);
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at)));
var fact=adapter.read(request("EMPTY_RETURN"),source());
assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt());
assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo());
}
@Test void directDeliveryReferencesOnlyItsOwnPhysicalSourceAndCustomer() {
var own=List.of(new Reference("CUSTOMER","7",null,"客户A"),new Reference("INBOUND","2","IN2","入仓单"));
var other=List.of(new Reference("CUSTOMER","8",null,"客户B"),new Reference("INBOUND","3","IN3","入仓单"));
var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of(
new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()),
new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of())));
when(direct.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h")));
var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of());
var fact=adapter.read(request,source);
assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus());
}
}
@@ -132,7 +132,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class));
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
@@ -176,7 +176,7 @@ class ContainerWorkbenchServiceTest {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class));
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.client.AppointmentWorkRouteClient;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class DirectDeliveryRouteGuardTest {
@Test void onlyConfirmedDirectRouteCanExecuteAndRemoteFailureIsNotUnknownSuccess() {
var client=mock(AppointmentWorkRouteClient.class);var guard=new DirectDeliveryRouteGuard(client,"token");
for(String flow:new String[]{"UNKNOWN","TRANSFER"}) {
when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route(flow)));
assertEquals(409,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value());
}
when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route("DIRECT")));
assertDoesNotThrow(()->guard.requireDirect(1));
when(client.route("token",1)).thenThrow(new IllegalStateException("offline"));
assertEquals(503,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value());
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class DirectDeliveryServiceTest {
private final DirectDeliveryMapper mapper=mock(DirectDeliveryMapper.class);
private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class);
private final DirectDeliveryService service=new DirectDeliveryService(mapper,nodes,new ObjectMapper().findAndRegisterModules(),mock(DirectDeliveryRouteGuard.class));
private final LocalDateTime at=LocalDateTime.parse("2026-01-03T12:00:00");
private DirectDeliveryMapper.Delivery pending(){return new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,0,null,null,null,null,null,null,null);}
private DirectDeliveryService.Request request(){return new DirectDeliveryService.Request(0,at,"America/Los_Angeles","收货人","POD-2","直送签收");}
@Test void recordsOnlyActualLoadedSourceAfterPickupAndReplaysWithoutDuplicateWrite() {
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null));
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
// Unknown pickup zone: a numerically later source-local clock is not evidence
// that the explicitly Los Angeles destination actual occurred first.
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"READY","PICKED_UP",at.plusHours(1),null,null,null,null,at.plusHours(1),null)));
doAnswer(inv->{
when(mapper.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,1,at,"America/Los_Angeles","收货人","POD-2","直送签收","k",inv.getArgument(8))));
return 1;
}).when(mapper).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString());
assertEquals(1,service.record(1,2,7,"admin","k",request()).version());
assertEquals(1,service.record(1,2,7,"admin","k",request()).version());
verify(mapper,times(1)).insert(eq(1L),eq(2L),eq(at),eq("America/Los_Angeles"),eq("收货人"),eq("POD-2"),any(),eq("k"),anyString(),eq(7L),eq("admin"));
}
@Test void refusesForeignSourceAndWarehouseReceiptWithoutWriting() {
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",at.minusDays(1)));
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
assertThrows(BizException.class,()->service.record(1,99,7,"a","k",request()));
assertThrows(BizException.class,()->service.record(1,2,7,"a","k",request()));
verifyNoInteractions(nodes);
verify(mapper,never()).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString());
}
@Test void refusesAmbiguousLocalTimeInsteadOfGuessingAnOffset() {
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null));
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
var ambiguous=new DirectDeliveryService.Request(0,LocalDateTime.parse("2025-11-02T01:30:00"),"America/Los_Angeles","收货人","POD",null);
assertThrows(BizException.class,()->service.record(1,2,7,"a","k",ambiguous));verifyNoInteractions(nodes);
}
}
@@ -46,8 +46,35 @@ class TerminalDrayageCalendarServiceTest {
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of());assertTrue(service.calendar(from,to).records().isEmpty());verifyNoInteractions(warehouses);
}
@Test void reportsTruncationInsteadOfClaimingCompleteResults() {
when(mapper.calendar(any(),any(),anyInt())).thenReturn(Collections.nCopies(1001,row(1,"US-W")));
when(mapper.calendar(any(),any(),anyInt())).thenReturn(java.util.stream.LongStream.rangeClosed(1,1001).mapToObj(id->row(id,"US-W")).toList());
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
var result=service.calendar(from,to);assertTrue(result.truncated());assertEquals(1000,result.records().size());
}
@Test void mergesActualMonthWithoutMovingExecutedWorkWhenPlansChangeOrDuplicatingTheContainer() {
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of(row(1,"US-W"),row(2,"US-W")));
when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(2L,3L));
var movedPlan=new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W","Fleet",to.plusDays(20).atStartOfDay(),null);
when(mapper.calendarByContainers(List.of(2L,3L))).thenReturn(List.of(row(2,"US-W"),movedPlan));
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
when(shipping.containerActualFacts(List.of(1L,2L,3L))).thenReturn(List.of(
fact(1L,to.atStartOfDay()),fact(2L,from.atStartOfDay()),fact(3L,from.plusDays(1).atStartOfDay())));
var result=service.calendar(from,to);
assertEquals(List.of(2L,3L),result.records().stream().map(PickupCalendarEntry::containerBookingId).toList());
assertEquals(to.plusDays(20).atStartOfDay(),result.records().get(1).pickupAppointmentAt());
assertEquals(from.plusDays(1).atStartOfDay(),result.records().get(1).actualPickupAt());
assertFalse(result.truncated());
}
private com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts fact(long id,java.time.LocalDateTime at) {
return new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts(id,null,null,null,null,null,null,null,null,null,at,null);
}
@Test void includesAnActualPickupWithoutInventingAnAppointment() {
when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(3L));
when(mapper.calendarByContainers(List.of(3L))).thenReturn(List.of(new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W",null,null,null)));
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
when(shipping.containerActualFacts(List.of(3L))).thenReturn(List.of(fact(3L,from.atStartOfDay())));
var result=service.calendar(from,to);
assertEquals(1,result.records().size());
assertNull(result.records().get(0).pickupAppointmentAt());
assertEquals(from.atStartOfDay(),result.records().get(0).actualPickupAt());
}
}
@@ -47,6 +47,7 @@ class TerminalDrayageServiceTest {
private CarrierScheduleQueryService carrierSchedules;
private TerminalDrayageService service;
private TerminalDrayageMailService mails;
private UnloadingEvidenceGuard unloadingEvidence;
private Deque<ContainerActualFacts> facts;
@BeforeEach
@@ -60,7 +61,8 @@ class TerminalDrayageServiceTest {
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
});
mails = mock(TerminalDrayageMailService.class);
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails);
unloadingEvidence = mock(UnloadingEvidenceGuard.class);
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails, unloadingEvidence);
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
}
@@ -115,7 +117,7 @@ class TerminalDrayageServiceTest {
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
.isInstanceOf(BizException.class)
.hasMessage("海外卸柜收货完成后才能确认还柜");
.hasMessage("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
}
@@ -138,6 +140,19 @@ class TerminalDrayageServiceTest {
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero();
}
@Test
void directReturnWarningUsesDestinationInstantInsteadOfServerWallClock() {
when(mapper.context(11L)).thenReturn(context("PICKED_UP", null));
when(mapper.find(11L)).thenReturn(row(3));
when(unloadingEvidence.directCompletion(11L)).thenReturn(new UnloadingEvidenceGuard.DirectCompletion(
LocalDateTime.parse("2026-09-01T22:00:00"), "America/Los_Angeles"));
var result=service.detail(11L);
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.parse("2026-09-04T22:00:00"));
assertThat(result.emptyReturnWarning().status()).isEqualTo("PENDING");
service.setClock(Clock.fixed(Instant.parse("2026-09-05T06:00:00Z"), ZoneOffset.ofHours(9)));
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isEqualTo(1);
}
@Test
void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() {
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class UnloadingEvidenceGuardTest {
@Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() {
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null);
when(mapper.container(1)).thenReturn(container);when(mapper.lockContainer(1)).thenReturn(container);
var at=LocalDateTime.parse("2026-10-10T12:00:00");
var first=new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,at,"America/Los_Angeles","张三","POD2",null,"k","h");
var pending=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,0,null,null,null,null,null,null,null);
when(mapper.deliveries(1)).thenReturn(List.of(first,pending));
assertNull(guard.completedUnloading(1));assertThrows(BizException.class,()->guard.requireUnloaded(1,at.plusDays(1)));
var second=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,at.plusHours(1),"America/Los_Angeles","李四","POD3",null,"k3","h3");
when(mapper.deliveries(1)).thenReturn(List.of(first,second));
assertEquals(at.plusHours(1),guard.completedUnloading(1));
assertDoesNotThrow(()->guard.requireUnloaded(1,at)); // Return has no source zone.
assertDoesNotThrow(()->guard.requireUnloaded(1,at.plusDays(1)));
var warehouse=new DirectDeliveryMapper.Container(1,"ACTIVE",at.plusHours(1));
when(mapper.container(1)).thenReturn(warehouse);when(mapper.lockContainer(1)).thenReturn(warehouse);
assertThrows(BizException.class,()->guard.requireUnloaded(1,at));
}
@Test void comparesDestinationEvidenceByInstantAndMeasuresSeventyTwoHoursAcrossDst() {
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
var ny=LocalDateTime.parse("2026-10-10T12:00:00");
var la=LocalDateTime.parse("2026-10-10T10:00:00");
when(mapper.deliveries(1)).thenReturn(List.of(
new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,ny,"America/New_York","a","P2",null,"k2","h2"),
new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,la,"America/Los_Angeles","a","P3",null,"k3","h3")));
assertEquals(la,guard.directCompletion(1).localAt());
var beforeFallback=new UnloadingEvidenceGuard.DirectCompletion(LocalDateTime.parse("2026-10-31T12:00:00"),"America/Los_Angeles");
assertEquals(LocalDateTime.parse("2026-11-03T11:00:00"),beforeFallback.deadline(72));
}
}
@@ -85,12 +85,56 @@
],
"sliceSpecificRules": {
"customer-statistics": {
"allowedOutgoing": ["shared-technical"],
"allowedOutgoing": [
"shared-technical"
],
"allowedIncoming": []
},
"pricebook-query": {
"allowedOutgoing": [],
"allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"]
"allowedIncoming": [
"booking-customs-trucking",
"measurement-pricing",
"split-loading"
]
}
}
},
"reviewedClassDependencies": [
{
"source": "com.baiyue.firstleg.controller.InternalOverseasContainerEventController",
"target": "com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer",
"reason": "海外事件HTTP适配器按事件类型派发到暂扣消费者;入仓数据仍由消费者维护。"
},
{
"source": "com.baiyue.firstleg.loading.service.LoadingDeliveryService",
"target": "com.baiyue.firstleg.service.FbaPricingService",
"reason": "装柜实物完成时按真实分配刷新费用,调用既有计价应用服务,不读取计价持久化。"
},
{
"source": "com.baiyue.firstleg.service.LoadingListService",
"target": "com.baiyue.firstleg.domain.PriceBookChannelCatalog",
"reason": "分柜按当前有效渠道目录校验,复用纯领域目录,不访问价格簿实现。"
},
{
"source": "com.baiyue.firstleg.service.LoadingListService",
"target": "com.baiyue.firstleg.service.FbaPricingService",
"reason": "装柜分配变动触发既有计价服务按客户重算,费用冻结门槛保留在原服务。"
},
{
"source": "com.baiyue.firstleg.service.SmartLoadingService",
"target": "com.baiyue.firstleg.service.FbaPricingService",
"reason": "智能排柜分配后调用原计价服务守恒重算,不跨域修改费用表。"
}
],
"reviewedPackageCycles": [
{
"packages": [
"com.baiyue.firstleg.loading.bridge.service",
"com.baiyue.firstleg.loading.service",
"com.baiyue.firstleg.oceanbooking.service",
"com.baiyue.firstleg.service"
],
"reason": "既有全局service包混合了入仓暂扣、装柜费用与订舱文档的应用编排。本轮不拆分这些无关既有服务;仅冻结当前四包SCC,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。"
}
]
}