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());
}
}