feat(lastleg): connect appointment followups and fleet intake

This commit is contained in:
2026-10-09 22:04:49 +08:00
parent 1202cafadf
commit 0d75e311d0
54 changed files with 2315 additions and 25 deletions
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentCargoMapperTest {
@Test void unplannedSailedCargoIsVisibleAndCreatingPlansDoesNotDuplicateTheSource() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:appointment-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var c=ds.getConnection(); var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,customer_name VARCHAR,inbound_code VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,overseas_warehouse_id VARCHAR,overseas_warehouse_code VARCHAR,overseas_warehouse_name VARCHAR,fba_warehouse_code VARCHAR,package_count INT,received_package_count INT,shipped_package_count INT,pallet_count INT,volume_cbm DECIMAL,estimated_arrival_at TIMESTAMP,estimated_warehouse_at TIMESTAMP,version INT,detained BOOLEAN,order_type VARCHAR,destination_type VARCHAR,current_status VARCHAR,created_at TIMESTAMP)");
s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR,node_sequence INT,standard_node VARCHAR,aggregate_version BIGINT,observed_at TIMESTAMP,provider_code VARCHAR)");
s.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT)");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR)");
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR,destination_warehouse_code VARCHAR)");
s.execute("INSERT INTO ll_order(id,customer_id,inbound_code,source_container_booking_id,overseas_warehouse_id,fba_warehouse_code,package_count,order_type,destination_type,current_status,created_at) VALUES(1,'A','IN-A','C1','100','ONT8',10,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP),(2,'B','IN-B','C2','100','ONT8',8,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP)");
s.execute("INSERT INTO ll_container_shipping_gate(container_booking_id,node_sequence) VALUES('C1',40),('C2',30)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCargoMapper.class);
try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentCargoMapper.class);
assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null));
var first=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0);
assertEquals("ORDER:1",first.get(0).getSourceKey()); assertFalse(first.get(0).isHasInboundPlan());
try (var s=session.getConnection().createStatement()) {
s.execute("INSERT INTO ll_inbound_plan VALUES(11,1,'A'),(12,1,'A')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(20,'SUCCESS')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20)");
}
session.clearCache();
var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0);
assertEquals(1,after.size()); assertTrue(after.get(0).isHasInboundPlan()); assertTrue(after.get(0).isHasActiveAppointment());
assertEquals(10,after.get(0).getPackages()); assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null));
assertEquals(0,mapper.count(List.of("200"),List.of("ONT8"),null,null,null));
}
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentCargoServiceTest {
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
var mapper = mock(AppointmentCargoMapper.class);
var warehouses = mock(AppointmentWarehouseClient.class);
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"),
new AppointmentWarehouseClient.Warehouse(2L,"CA-WH","加拿大仓","OVERSEAS","CA"),
new AppointmentWarehouseClient.Warehouse(3L,"ONT8","ONT8","FBA","US"),
new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"))));
var row = new AppointmentCargo(); row.setOrderId(99L); row.setSourceKey("ORDER:99"); row.setHasInboundPlan(false);
when(mapper.count(List.of("1"),List.of("ONT8"),null,null,"WT")).thenReturn(21L);
when(mapper.page(List.of("1"),List.of("ONT8"),null,null,"WT",20,20)).thenReturn(List.of(row));
var result = new AppointmentCargoService(mapper,warehouses,"token").page(2,20,null,null," WT ");
assertEquals(21,result.total()); assertEquals(2,result.pages()); assertEquals(1,result.records().size());
assertFalse(result.records().get(0).isHasInboundPlan());
verify(warehouses,times(1)).catalog("token");
}
@Test void directoryFailureIsNotReportedAsAnEmptyAmericanCargoList() {
var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class);
when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline"));
var error = assertThrows(ResponseStatusException.class, () -> new AppointmentCargoService(mapper,warehouses,"token").page(1,20,null,null,null));
assertEquals(503,error.getStatusCode().value()); verifyNoInteractions(mapper);
}
@Test void invalidPaginationAndNonUsFiltersNeverQueryCargo() {
var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class);
var service = new AppointmentCargoService(mapper,warehouses,"token");
assertThrows(ResponseStatusException.class, () -> service.page(0,20,null,null,null));
verifyNoInteractions(warehouses);
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of()));
assertThrows(ResponseStatusException.class, () -> service.page(1,20,"unknown",null,null));
verifyNoInteractions(mapper);
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentCoverageMapperTest {
@Test void readsOnlyMatchingCustomerAndPlanLinesAndExcludesCancelledAppointments() throws Exception {
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)");
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))");
s.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),required_package_count INT,fba_warehouse_code VARCHAR(128))");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),line_id BIGINT,plan_id BIGINT)");
s.execute("INSERT INTO ll_order VALUES(1,'A','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FBA','ONT8',8,1,CURRENT_TIMESTAMP)");
s.execute("INSERT INTO ll_order_detail VALUES(1,'A','SHIP-A',NULL),(2,'B',NULL,NULL)");
s.execute("INSERT INTO ll_inbound_plan_line VALUES(10,100,1,'A',20,'ONT8'),(20,200,2,'B',8,'ONT8')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'APP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP)");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A',10,100),(2,1000,2,'B',20,200),(3,2000,1,'A',10,100),(4,1000,1,'A',20,200)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCoverageMapper.class);
try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentCoverageMapper.class);
var refs=List.of(new AppointmentCoverageMapper.OrderRef("A",1L));
var source=mapper.sources(refs).get(0); assertFalse(source.shipmentReferencesMissing()); assertEquals(20,source.packages());
var facts=mapper.appointments(refs); assertEquals(1,facts.size()); assertEquals("APP-1",facts.get(0).appointmentNo()); assertEquals(20,facts.get(0).packages());
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",1L))).isEmpty());
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing());
try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_order_detail VALUES(1,'A',' ',NULL)"); }
session.clearCache(); assertTrue(mapper.sources(refs).get(0).shipmentReferencesMissing());
}
}
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.appointmentplanning;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentCoverageServiceTest {
private static final LocalDateTime BEFORE=LocalDateTime.of(2026,10,9,1,0), AFTER=BEFORE.plusHours(1);
private static AppointmentCoverageMapper.Source order() {
return new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,false);
}
private static AppointmentCoverageMapper.Fact fact(long line,int packages,String status) {
return new AppointmentCoverageMapper.Fact(1L,"A","FBA-CONFIRM-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8");
}
@Test void completeQuantitySharesOneAppointmentButDoesNotInventItsTimezone() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
when(mapper.sources(anyList())).thenReturn(List.of(order()));
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(11,10,"SUCCESS")));
var result=new AppointmentCoverageService(mapper,gateway).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
assertEquals("CONFIRMED",result.status()); assertEquals(1,result.appointments().size());
assertNull(result.appointments().get(0).timezone()); assertTrue(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway);
}
@Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() {
var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order()));
var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class));
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS")));
assertEquals("PARTIAL",service.query(refs).get(0).status());
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"PENDING")));
assertEquals("PENDING",service.query(refs).get(0).status());
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(10,10,"SUCCESS")));
assertEquals("REVIEW_REQUIRED",service.query(refs).get(0).status());
}
@Test void externalBookingKeepsItsIdentityBeforeAndAfterRealCargoExists() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8"));
var service=new AppointmentCoverageService(mapper,gateway);
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L)));
assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verifyNoInteractions(mapper);
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","BOUND","100","FBA:ONT8",1L,20,2L)));
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
var result=service.query(refs).get(0); assertEquals("BOOKING:B-1",result.sourceKey()); assertEquals("CONFIRMED",result.status());
assertThrows(IllegalArgumentException.class, () -> service.query(List.of(refs.get(0),new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))));
}
@Test void anotherCustomerAndChangedDestinationCannotBorrowAppointmentCoverage() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
var service=new AppointmentCoverageService(mapper,gateway);
assertEquals("SOURCE_NOT_FOUND",service.query(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).get(0).status());
assertEquals("DESTINATION_MISMATCH",service.query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))).get(0).status());
}
@Test void changedSourcesAndMissingShipmentReferencesAreExplicitWithoutChangingConfirmedTime() {
var mapper=mock(AppointmentCoverageMapper.class);
when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,3,AFTER.plusHours(1),true)));
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
var result=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
assertTrue(result.changeReviewRequired()); assertTrue(result.missingFields().containsAll(List.of("SOURCE_CHANGED_REVIEW","FBA_SHIPMENT_REFERENCES")));
assertEquals(AFTER.plusDays(1),result.appointments().get(0).confirmedAt());
verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verifyNoMoreInteractions(mapper);
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentFollowupPersistenceTest {
@Test void baselineAndMapperPersistSeparateStagesAndEnforceCommandAndVersionUniqueness() throws Exception {
String sql;
try (var input=getClass().getResourceAsStream("/db/lastleg.sql")) {
assertNotNull(input); var baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8);
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_followup (");
assertTrue(start>=0); sql=baseline.substring(start,baseline.indexOf(';',start)+1);
}
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:followup-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY)"); s.execute("INSERT INTO ll_order VALUES(1)"); s.execute(sql);
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentFollowupMapper.class);
try (var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentFollowupMapper.class);
var first=record(11L,"DRAYAGE_CONTACT",1,"key-a"); mapper.insert(first);
mapper.insert(record(12L,"FBA_RECEIVING",1,"key-a"));
assertEquals(1,mapper.version("ORDER:1","A","DRAYAGE_CONTACT")); assertEquals(1,mapper.version("ORDER:1","A","FBA_RECEIVING"));
assertEquals(2,mapper.count("ORDER:1","A")); assertEquals("联系结果",mapper.command("ORDER:1","A","DRAYAGE_CONTACT","key-a").getResult());
assertEquals(1,mapper.page("ORDER:1","A",1,0).size());
assertThrows(RuntimeException.class, () -> mapper.insert(record(13L,"DRAYAGE_CONTACT",2,"key-a")));
assertThrows(RuntimeException.class, () -> mapper.insert(record(14L,"DRAYAGE_CONTACT",1,"key-b")));
assertEquals(2,mapper.count("ORDER:1","A"));
}
}
private static AppointmentFollowup record(long id,String stage,int version,String key) {
var row=new AppointmentFollowup(); row.setId(id); row.setSourceOrderId(1L); row.setSourceKey("ORDER:1"); row.setCustomerId("A"); row.setStage(stage); row.setVersion(version);
row.setResult("联系结果"); row.setOperatorId("operator"); row.setCommandKey(key); row.setRequestHash("a".repeat(64));
row.setCreatedAt(LocalDateTime.of(2026,10,9,12,0)); return row;
}
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.appointmentplanning;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentFollowupServiceTest {
@Test void contactsBeforePlanningAreAppendOnlyAndOldCommandsReplayTheirOriginalResult() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
var saved=new HashMap<String,AppointmentFollowup>(); var version=new int[]{0};
when(cargo.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,"C1","100","美国仓","FBA:ONT8","ONT8",10,"ORDER:0",true,List.of()));
when(mapper.command(eq("ORDER:1"),eq("A"),eq("DRAYAGE_CONTACT"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3)));
when(mapper.version("ORDER:1","A","DRAYAGE_CONTACT")).thenAnswer(call -> version[0]);
when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> {
AppointmentFollowup row=call.getArgument(0); saved.put(row.getCommandKey(),row); version[0]=row.getVersion(); return 1;
});
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules());
var first=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first","车队A","已联系,等待回复","明日再联系", LocalDate.of(2026,10,15));
var result=service.save("ORDER:1","A",first,"operator","运营甲");
service.save("ORDER:1","A",new AppointmentFollowupService.Save("DRAYAGE_CONTACT",1,"second","车队A","尚未给出正式时段",null,null),"operator","运营甲");
var replay=service.save("ORDER:1","A",first,"operator","运营甲");
assertEquals(result,replay); assertEquals(1,replay.version()); assertEquals(2,saved.size());
assertEquals("运营甲",result.operatorName()); assertEquals("已联系,等待回复",result.result());
verify(mapper,times(2)).insert(any(AppointmentFollowup.class));
verify(cargo,times(2)).resolve("ORDER:1","A",null);
var conflict=assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first",null,"不同结果",null,null),"operator","运营甲"));
assertEquals(409,conflict.getStatusCode().value());
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",first,"another-operator","运营乙"));
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"stale",null,"并发旧版本",null,null),"operator","运营甲"));
assertEquals(2,saved.size());
}
@Test void followupDateRequiresAnActionAndInvalidStagesDoNotWrite() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper());
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"等待材料",null,LocalDate.of(2026,10,15)),"operator","运营"));
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("CONFIRM_ALL",0,"key",null,"不能混用",null,null),"operator","运营"));
verifyNoInteractions(cargo,mapper);
}
@Test void existingHistoryRemainsReadableWhenTheCargoHasLeftTheActivePlanningList() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
when(mapper.count("ORDER:1","A")).thenReturn(21L); when(mapper.page("ORDER:1","A",20,20)).thenReturn(List.of());
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper());
var history=service.history("ORDER:1","A",2,20);
assertEquals(21,history.history().total()); assertEquals(2,history.history().pages());
verifyNoInteractions(cargo);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentPlanningAuthorizationTest {
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
@Test void readRequiresBothSourcePermissionsAndCannotWrite() {
try (var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
var user=user(0L,"PLATFORM");
for (String permission:List.of(LIST,CARGO)) {
login(permission);
assertThrows(AccessDeniedException.class,() -> controller.cargo(user,1,20,null,null,null));
assertThrows(AccessDeniedException.class,() -> controller.history(user,"ORDER:1","A",1,20));
}
login(LIST,CARGO);
controller.cargo(user,1,20,null,null,null);
verify(context.getBean(AppointmentCargoService.class)).page(1,20,null,null,null);
assertThrows(AccessDeniedException.class,() -> controller.followup(user,"ORDER:1","A",null));
verifyNoInteractions(context.getBean(AppointmentFollowupService.class));
} finally { SecurityContextHolder.clearContext(); }
}
@Test void permissionDoesNotAllowClientIdentityAndOperatorComesFromLogin() {
try (var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
login(LIST,CARGO,WRITE);
var request=new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"已联系",null,null);
assertThrows(ResponseStatusException.class,() -> controller.followup(user(1L,"CLIENT"),"BOOKING:B-1","A",request));
assertThrows(ResponseStatusException.class,() -> controller.cargo(null,1,20,null,null,null));
controller.followup(user(0L,"PLATFORM"),"BOOKING:B-1","A",request);
verify(context.getBean(AppointmentFollowupService.class)).save("BOOKING:B-1","A",request,"7","operator");
} finally { SecurityContextHolder.clearContext(); }
}
private static AuthenticatedUser user(Long customer,String app) { return new AuthenticatedUser(7L,customer,0L,8L,"operator",app,"WEB",Set.of()); }
private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); }
@Configuration @EnableMethodSecurity
static class Config {
@Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); }
@Bean AppointmentFollowupService followups() { return mock(AppointmentFollowupService.class); }
@Bean AppointmentSourceService source() { return mock(AppointmentSourceService.class); }
@Bean AppointmentCoverageService coverage() { return mock(AppointmentCoverageService.class); }
@Bean AppointmentPlanningController controller(AppointmentCargoService cargo,AppointmentFollowupService followups,AppointmentSourceService source,AppointmentCoverageService coverage) { return new AppointmentPlanningController(cargo,followups,source,coverage); }
}
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.appointmentplanning;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentPlanningPolicyTest {
private static Instant at(int hour) { return Instant.parse("2026-10-15T" + String.format("%02d", hour) + ":00:00Z"); }
private static AppointmentPlanningPolicy.Cargo cargo(String source, String customer, String origin, String destination,
Integer packages, Instant from, Instant to) {
return new AppointmentPlanningPolicy.Cargo(source, customer, origin, destination, "US", "US", packages, from, to, true, null);
}
@Test void twoCustomersCanPlanOneDestinationWithoutAPlanNumberDriverOrExternalConfirmation() {
var result = AppointmentPlanningPolicy.group(List.of(
cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(5)),
cargo("BOOKING:B-1", "B", "WH-1", "FBA:ONT8", 8, at(3), at(8))));
assertEquals(18, result.packages()); assertEquals(2, result.customerCount());
assertEquals(at(3), result.readyFrom()); assertEquals(at(5), result.readyTo());
assertFalse(result.readinessIncomplete());
}
@Test void unknownReadinessIsPreservedInsteadOfInventingAnArrivalOffset() {
var result = AppointmentPlanningPolicy.group(List.of(cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, null, null)));
assertTrue(result.readinessIncomplete()); assertNull(result.readyFrom()); assertNull(result.readyTo());
}
@Test void differentOriginsDestinationsAndDisjointWindowsAreNotSilentlyMerged() {
var first = cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(3));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first,
cargo("ORDER:2", "B", "WH-2", "FBA:ONT8", 8, at(1), at(3)))));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first,
cargo("ORDER:2", "B", "WH-1", "FBA:LAX9", 8, at(1), at(3)))));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first,
cargo("ORDER:2", "B", "WH-1", "FBA:ONT8", 8, at(5), at(8)))));
}
@Test void sourceIdentityMustNotBeCountedAgainAfterMaterialization() {
var first = cargo("BOOKING:B-1", "A", "WH-1", "FBA:ONT8", 10, null, null);
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, first)));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null))));
}
@Test void nonUsAndBlockedSourcesCannotBeTreatedAsEligibleAmericanCargo() {
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:YYZ", "US", "CA", 1, null, null, true, null))));
var error = assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", "US", "US", 1, null, null, false, "货物已被有效安排占用"))));
assertEquals("货物已被有效安排占用", error.getMessage());
}
@Test void changedReadinessIdentifiesImpactWithoutOverwritingTheConfirmedTime() {
var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3));
var after = new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", at(4), at(8));
var impact = AppointmentPlanningPolicy.compare(before, after, at(6));
assertTrue(impact.reviewRequired()); assertEquals("READINESS_AFTER_APPOINTMENT", impact.reason());
assertEquals(at(6), impact.confirmedAt()); assertEquals(at(1), impact.previousEta()); assertEquals(at(4), impact.latestEta());
assertFalse(AppointmentPlanningPolicy.compare(after, after, at(6)).reviewRequired());
}
@Test void lostForecastNeedsReviewAndUnrelatedSourcesCannotBeCompared() {
var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3));
assertEquals("FORECAST_MISSING", AppointmentPlanningPolicy.compare(before,
new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", null, null), at(6)).reason());
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.compare(before,
new AppointmentPlanningPolicy.Forecast("ORDER:2", "2", at(2), at(3)), at(6)));
}
@Test void losAngelesTimesResolveToTheLocationZoneAndKeepAnUnknownEndUnknown() {
var result = AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "America/Los_Angeles", null, null);
assertEquals(Instant.parse("2026-10-15T16:00:00Z"), result.start()); assertNull(result.end());
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, null, null, null));
}
@Test void daylightSavingGapIsRejectedAndOverlapRequiresAnExplicitValidOffset() {
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-03-08T02:30:00"), null, "America/Los_Angeles", null, null));
var overlap = LocalDateTime.parse("2026-11-01T01:30:00");
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", null, null));
assertEquals(Instant.parse("2026-11-01T09:30:00Z"), AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(-8), null).start());
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(8), null));
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentSourceServiceTest {
@Test void confirmedExternalBookingCanBeFollowedWithoutCreatingAnOrderAndKeepsItsKeyAfterMaterialization() {
var orders=mock(LastlegOrderMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class);
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),
new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
var service=new AppointmentSourceService(orders,mock(ContainerShippingGateMapper.class),gateway,warehouse,"token");
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,null,1L)));
var source=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertTrue(source.canFollowup()); assertNull(source.orderId()); assertNull(source.packages()); verifyNoInteractions(orders);
var order=new LastlegOrder(); order.setId(1L); order.setCustomerId("A"); order.setOverseasWarehouseId("100"); order.setPackageCount(12); order.setInboundCode("IN-A"); order.setDestinationType("FBA"); order.setFbaWarehouseCode("ONT8");
when(orders.selectById(1L)).thenReturn(order);
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,10,2L)));
var received=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertEquals("BOOKING:B-1",received.sourceKey()); assertEquals(12,received.packages()); assertEquals("IN-A",received.inboundCode());
assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:LAX9"));
assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","OTHER","FBA:ONT8"));
order.setCurrentStatus("CANCELLED"); assertFalse(service.resolve("BOOKING:B-1","A","FBA:ONT8").canFollowup());
order.setFbaWarehouseCode("LAX9"); assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:ONT8"));
}
@Test void draftBookingCannotBePresentedAsConfirmedCarrierCapacity() {
var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class);
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","DRAFT","100","FBA:ONT8",null,null,0L)));
var source=new AppointmentSourceService(mock(LastlegOrderMapper.class),mock(ContainerShippingGateMapper.class),gateway,warehouse,"token").resolve("BOOKING:B-1","A","FBA:ONT8");
assertFalse(source.canFollowup()); assertFalse(source.blockReasons().isEmpty());
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.fleet.trip.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetDispatchPlanningAdapterTest {
private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class);
private final DispatchService service=mock(DispatchService.class);
private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class);
private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway);
private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);}
@Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() {
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2"));
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车");
verifyNoInteractions(service);
}
@Test void reusesOnlyAnExactExistingShipment() {
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1"));
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10));
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2")));
assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service);
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2")));
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收");
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2")));
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源");
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.fleet.business.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class FleetPlanningReadPersistenceTest {
@Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))");
jdbc.execute("CREATE TABLE ll_dispatch_item(dispatch_order_id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(dispatch_order_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_outbound_order(dispatch_order_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,warehouse_id BIGINT)");
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT,trip_id BIGINT,customer_id VARCHAR(64),booking_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),offer_id BIGINT,destination_key VARCHAR(160),destination_label VARCHAR(100),incoming_no VARCHAR(64),record_version BIGINT,cargo_json VARCHAR(2000),expected_pallet_count INT,estimated_arrival_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(booking_id BIGINT,customer_id VARCHAR(64),lastleg_order_id BIGINT,business_order_no VARCHAR(64),fulfillment_stage VARCHAR(32))");
for(int i=1;i<=6;i++)jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,customer_id,overseas_warehouse_id,package_count,status,picked_package_count,shipped_package_count,updated_at) VALUES(?,?,?,'92',10,'PENDING_DEPARTURE',0,0,CURRENT_TIMESTAMP)",i,"DS-"+i,"9");
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(2)");
jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='93' WHERE id=3");
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(4,'PICKING')");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(5,'9','IN-5'),(6,'9','IN-6')");
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,92)");
jdbc.update("INSERT INTO ll_fleet_external_booking(id,trip_id,customer_id,booking_no,trip_no,status,offer_id,destination_key,destination_label,incoming_no,record_version,cargo_json) VALUES(5,1,'9','FB-5','TR-OTHER','RECEIVED',1,'FBA:ACY2','ACY2','IN-5',1,'{\"packageCount\":null}'),(6,1,'9','FB-6','TR-1','RECEIVED',1,'FBA:ACY2','ACY2','IN-6',2,'{\"packageCount\":10}')");
var configuration=new MybatisConfiguration();configuration.addMapper(FleetDispatchPlanningMapper.class);configuration.addMapper(FleetPoolOrderMapper.class);
var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class);
var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null);
assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1);
assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1");
var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper());
var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6")));
assertThat(sources).hasSize(2);
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-5")).findFirst().orElseThrow().packageCount()).isNull();
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-6")).findFirst().orElseThrow().packageCount()).isEqualTo(10);
assertThat(sources).allMatch(s->s.lastlegOrderId()==null&&s.expectedPalletCount()==null);
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class FleetInquiryProgressTest {
private final Instant cutoff=Instant.parse("2026-10-20T10:00:00Z");
private FleetCapacityOfferModels.Offer offer() {
return new FleetCapacityOfferModels.Offer("FO-1","TR-1","OPEN",1,1,"1","仓库",cutoff,cutoff,null,null,null,
List.of(new FleetCapacityOfferModels.Destination("FBA:ACY2","ACY2",1,new TripCapacityPolicy.Load(4,new BigDecimal("1000"),new BigDecimal("10")))),List.of(),true,true,true,null,null);
}
private FleetBookingModels.Cargo cargo(Integer spaces) {
return new FleetBookingModels.Cargo("FBA:ACY2","货物",1,spaces,new BigDecimal("100"),BigDecimal.ONE,"PALLET",BigDecimal.ZERO,"USD","客户联系人","联系信息",null);
}
private FleetBookingEntity booking(String status) {
var row=new FleetBookingEntity();row.setStatus(status);row.setBusinessType("LOCAL_LOOSEBOARD");row.setEstimatedArrivalAt(LocalDateTime.ofInstant(cutoff.minusSeconds(60),ZoneOffset.UTC));return row;
}
@Test void unknownQuantityIsMissingAndTruckNeedsPhysicalPalletCount() {
var row=booking("DRAFT");row.setBusinessType("US_TRUCK");
assertThat(FleetInquiryProgress.missing(row,cargo(null),offer())).containsExactly("占用标准托位","预计实托数");
row.setExpectedPalletCount(1);
assertThat(FleetInquiryProgress.missing(row,cargo(1),offer())).isEmpty();
}
@Test void capacityIsCheckedAgainstThisDestinationAndThisShipment() {
assertThat(FleetInquiryProgress.capacityBlock(cargo(5),offer())).contains("超过");
assertThat(FleetInquiryProgress.capacityBlock(cargo(4),offer())).isNull();
}
@Test void expiryDoesNotTellSalesToEditAnUnsavableDraft() {
var p=FleetInquiryProgress.project(booking("DRAFT"),List.of("预计重量"),"已截止",false,false,null,null);
assertThat(p.primaryAction()).isEqualTo("VIEW");assertThat(p.reservation()).isEqualTo("尚未保位");
}
@Test void receivingIsNotDispatchWorkUntilActualMeasurementCompletes() {
var row=booking("CONFIRMED");row.setIncomingNo("WT-1");
var waiting=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_PDA_RECEIVING",null);
assertThat(waiting.owner()).isEqualTo("仓库");assertThat(waiting.primaryAction()).isEqualTo("VIEW");
var received=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_CUSTOMER_CONFIRMATION",null);
assertThat(received.owner()).isEqualTo("调度");assertThat(received.primaryAction()).isEqualTo("RECEIPT");
}
@Test void bindingIsNotDepartureAndOnlyActualFulfillmentAdvancesTracking() {
assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"PENDING_DISPATCH").label()).isEqualTo("已排入本车");
assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"IN_TRANSIT").label()).isEqualTo("已发车");
}
}
@@ -13,11 +13,15 @@ import static org.assertj.core.api.Assertions.*;
class FleetTripApiContractTest {
@Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception {
Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml")));
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
Map<String, Object> preparation = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-preparation-v1.yaml")));
Map<String, Object> pool = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-capacity-offers-v1.yaml")));
var paths = new java.util.HashMap<Object,Object>();
for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map<?,?>)spec.get("paths"));
String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql"));
String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
int count = 0;
for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) {
for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) {
String base = controller.getAnnotation(RequestMapping.class).value()[0];
for (var method : controller.getDeclaredMethods()) {
String verb = null; String[] suffix = null;
@@ -42,8 +46,10 @@ class FleetTripApiContractTest {
count++;
}
}
assertThat(count).isEqualTo(29);
assertThat(count).isEqualTo(52);
verifyRefs(document, document);
verifyRefs(preparation, preparation);
verifyRefs(pool, pool);
}
@Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception {
@@ -68,7 +74,7 @@ class FleetTripApiContractTest {
if(!reference.startsWith("#/")) {
String[] parts=reference.split("#",2);
assertThat(parts).hasSize(2);
assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml");
assertThat(Path.of(parts[0]).getFileName().toString()).isIn("fleet-quote-candidates-v1.yaml","fleet-trips-v1.yaml","fleet-capacity-offers-v1.yaml");
try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); }
catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); }
pointer="#"+parts[1];
@@ -36,6 +36,7 @@ class FleetTripAuthorizationTest {
assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.prepare(null,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class);
@@ -55,8 +56,25 @@ class FleetTripAuthorizationTest {
verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
@Test void poolReadDoesNotGrantIncomingReceiptOrDispatchCommands() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
var pool=context.getBean(PlatformFleetCapacityOfferController.class);
pool.fulfillment("FB-1");verify(context.getBean(FleetCapacityOfferService.class)).fulfillment("FB-1");
assertThatThrownBy(()->pool.incoming("FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->pool.receipt("FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->pool.arrange("FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class);
} finally {SecurityContextHolder.clearContext();}
}
@Configuration @EnableMethodSecurity
static class Config {
@Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); }
@Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); }
@Bean FleetTripPreparationService preparation() { return mock(FleetTripPreparationService.class); }
@Bean com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage() { return mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class); }
@Bean FleetCapacityOfferService capacityOffers() { return mock(FleetCapacityOfferService.class); }
@Bean PlatformFleetCapacityOfferController poolController(FleetCapacityOfferService service) { return new PlatformFleetCapacityOfferController(service); }
@Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); }
@Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); }
@Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); }
@@ -56,6 +56,8 @@ import static org.mockito.Mockito.*;
class FleetTripPersistenceTest {
private JdbcTemplate jdbc;
private FleetTripService service;
private FleetTripPreparationService preparation;
private FleetDispatchPlanningGateway planning;
private DriverService drivers;
private VehicleRepository vehicles;
private VehicleApplicationService vehicleService;
@@ -141,6 +143,9 @@ class FleetTripPersistenceTest {
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
bindingMapper, dispatchGateway, reservations), FleetTripService.class);
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
planning=mock(FleetDispatchPlanningGateway.class);
ReflectionTestUtils.setField(service,"planning",planning);
execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class);
var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
@@ -150,11 +155,47 @@ class FleetTripPersistenceTest {
quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class);
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class);
ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders);
ReflectionTestUtils.setField(bookingService,"planning",planning);
ReflectionTestUtils.setField(bookingService,"commands",session.getMapper(FleetCapacityOfferCommandMapper.class));
capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),
session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class);
ReflectionTestUtils.setField(capacityOffers,"appointmentCoverage",mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class));
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
}
@Test void preparationRollsBackNewTripAndResourcesIfAnyBindingFailsThenReplaysSuccess() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of("platform:fleet:trip:create","platform:fleet:trip:assign","platform:fleet:trip:edit").stream().map(SimpleGrantedAuthority::new).toList()));
long vehicle=vehicle(true), driver=driver(true);dispatch(21,"A","92");dispatch(22,"B","93");
var request=new FleetTripPreparationService.Prepare(null,null,createRequest("prepare-atomic"),vehicle,driver,List.of("DP-21","DP-22"),"prepare-atomic");
assertThatThrownBy(()->preparation.prepare("1",request)).hasMessageContaining("同一起运仓");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero();
jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=22");
jdbc.update("UPDATE ll_dispatch_pda_task SET warehouse_id='92' WHERE dispatch_order_id=22");
var saved=preparation.prepare("1",request);
assertThat(saved.dispatches()).hasSize(2);
assertThat(preparation.prepare("1",request)).isEqualTo(saved);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->preparation.prepare("1",new FleetTripPreparationService.Prepare(null,null,request.plan(),vehicle,driver,List.of("DP-21"),request.idempotencyKey()))).hasMessageContaining("不能更换内容");
}
@Test void sameWindowSaveAndConfirmIsAtomicAndReplaysOriginalResult() {
var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"prepare-register"));
var input=poolDraft(draft.offer(),2,"same-window-confirm");
var update=new FleetCapacityOfferModels.InquiryWrite(input.tripRecordVersion(),input.offerRecordVersion(),draft.inquiry().recordVersion(),input.customerId(),input.businessType(),input.deliveryMode(),input.estimatedArrivalAt(),input.expectedPalletCount(),input.cargo(),input.quoteRef(),input.idempotencyKey());
doThrow(new IllegalStateException("bridge failed")).when(poolOrders).confirm(any(),anyString());
assertThatThrownBy(()->capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update)).hasMessageContaining("bridge failed");
assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).recordVersion()).isEqualTo(draft.inquiry().recordVersion());
assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT");
reset(poolOrders);
var confirmed=capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update);
assertThat(confirmed.inquiry().status()).isEqualTo("CONFIRMED");
assertThat(confirmed.inquiry().progress().reservation()).isEqualTo("已保留货位");
assertThat(capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update).replayed()).isTrue();
verify(poolOrders,times(1)).confirm(any(),anyString());
}
@Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() {
var offer=publishedOffer();poolLogin(101,false);
var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first"));
@@ -443,7 +484,8 @@ class FleetTripPersistenceTest {
} finally { executor.shutdownNow(); }
}
@Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() {
@org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic) {
var source=enableQuote();
String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
@@ -451,7 +493,7 @@ class FleetTripPersistenceTest {
var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null));
var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0);
when(incoming.create(eq("1"),any())).thenReturn(awaiting);
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start");
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start");
var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request);
assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue();
verify(incoming,times(1)).create(eq("1"),any());
@@ -473,11 +515,20 @@ class FleetTripPersistenceTest {
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
dispatch(13,"9","92");
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')");
var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external"));
when(incoming.inspect("9","WT-EXT",true)).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages()));
when(planning.resolveIncoming(eq("9"),any(),eq("1"))).thenReturn("DP-13");
var arrange=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"auto-link-external",null);
var linked=automatic?bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange):bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external"));
if(automatic){assertThat(bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange).replayed()).isTrue();verify(planning,times(1)).resolveIncoming(eq("9"),any(),eq("1"));}
assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13");
assertThat(execution.capacityPlan(number).planValid()).isFalse();
assertThat(service.detail(number).dispatches()).hasSize(3);
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联"));
var recheck=execution.capacityPlan(number);
var planned=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(recheck.recordVersion(),recheck.manifestHash(),List.of(measurement(),new FleetExecutionModels.Measurement("902",1,new BigDecimal("150"),new BigDecimal("1.5"),1)),"关联实收后核对"));
assertThat(planned.capacityPlan().segments().get(0).occupied().palletSpaces()).isEqualTo(2);
assertThat(planned.availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
if(automatic){var replay=bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange);assertThat(replay.tripRecordVersion()).isEqualTo(linked.tripRecordVersion());assertThat(replay.booking()).isEqualTo(linked.booking());}
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联"));
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED");
assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull();
}