feat(appointment): share pickup and receiving followups by container

This commit is contained in:
2026-10-10 08:42:01 +08:00
parent 885cac13a4
commit 24f5ccf7ff
14 changed files with 513 additions and 21 deletions
@@ -0,0 +1,39 @@
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 AppointmentContainerMapperTest {
@Test void groupsWholeContainersAcrossCustomersWithoutFbaOnlyFilterOrSearchTruncation() throws Exception {
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:containers-"+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,source_container_booking_id VARCHAR(64),container_no VARCHAR(64),order_type VARCHAR(32),current_status VARCHAR(32),overseas_warehouse_id VARCHAR(64),customer_id VARCHAR(64),inbound_code VARCHAR(64),package_count INT,estimated_arrival_at TIMESTAMP,created_at TIMESTAMP)");
s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR(64),container_no VARCHAR(64),node_sequence INT,standard_node VARCHAR(64),aggregate_version BIGINT)");
s.execute("INSERT INTO ll_container_shipping_gate VALUES('C1','BOX-1',40,'DEPARTED',1),('C2','BOX-2',30,'READY',1),('C3','BOX-3',40,'DEPARTED',1)");
s.execute("INSERT INTO ll_order VALUES(1,'C1','BOX-1','FIRST_LEG','PENDING_ASSIGNMENT','100','A','IN-A',10,NULL,CURRENT_TIMESTAMP),(2,'C1','BOX-1','FIRST_LEG','INBOUND_PLANNED','100','B','IN-B',20,NULL,CURRENT_TIMESTAMP),(3,'C1','BOX-1','FIRST_LEG','PENDING_ASSIGNMENT','200','A','IN-C',NULL,NULL,CURRENT_TIMESTAMP),(4,'C1','BOX-1','FIRST_LEG','PENDING_ASSIGNMENT','999','C','OUT-US',4,NULL,CURRENT_TIMESTAMP),(5,'C2','BOX-2','FIRST_LEG','PENDING_ASSIGNMENT','100','A','UNSAILED',1,NULL,CURRENT_TIMESTAMP),(6,'C3','BOX-3','FIRST_LEG','PENDING_ASSIGNMENT','999','C','FOREIGN',1,NULL,CURRENT_TIMESTAMP)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentContainerMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentContainerMapper.class);var origins=List.of("100","200");
assertEquals(1,mapper.count(origins,null,null));
var row=mapper.page(origins,"IN-A",null,20,0).get(0);
assertEquals("C1",row.getContainerBookingId());assertEquals("BOX-1",row.getContainerNo());
assertEquals(3,row.getOrderCount());assertEquals(2,row.getCustomerCount());assertNull(row.getPackages());
assertEquals(1,row.getUnknownPackageOrders());assertTrue(row.isIncompleteScope());
var groups=mapper.warehouses(origins,List.of("C1"));assertEquals(2,groups.size());
assertEquals(2,groups.get(0).orderCount());assertEquals(2,groups.get(0).customerCount());
assertTrue(mapper.page(origins,null,"C3",20,0).isEmpty());
try(var s=session.getConnection().createStatement()) {s.execute("UPDATE ll_order SET current_status='CANCELLED' WHERE id=4");s.execute("UPDATE ll_order SET package_count=5 WHERE id=3");}
session.clearCache();var updated=mapper.page(origins,null,"C1",20,0).get(0);
assertFalse(updated.isIncompleteScope());assertEquals(35,updated.getPackages());
}
}
}
@@ -0,0 +1,35 @@
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.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentContainerServiceTest {
@Test void usesOneBatchedWarehouseReadAndRejectsAnUnrelatedReceivingPoint() {
var mapper=mock(AppointmentContainerMapper.class);var warehouse=mock(AppointmentWarehouseClient.class);
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US","US receiving","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"OTHER","Foreign","OVERSEAS","GB"))));
var row=new AppointmentContainer();row.setContainerBookingId("C1");row.setContainerNo("BOX-1");row.setOrderCount(2);row.setCustomerCount(2);
when(mapper.count(anyList(),isNull(),isNull())).thenReturn(1L);
when(mapper.page(anyList(),isNull(),any(),anyInt(),anyLong())).thenReturn(List.of(row));
when(mapper.warehouses(anyList(),anyList())).thenReturn(List.of(new AppointmentContainerMapper.WarehouseCount("C1","100",2,2)));
var service=new AppointmentContainerService(mapper,warehouse,"token");
var page=service.page(1,20,null);assertEquals("US receiving",page.records().get(0).getWarehouses().get(0).warehouseName());
verify(mapper).warehouses(List.of("100"),List.of("C1"));
assertEquals("US receiving",service.context("C1","100").warehouseName());
assertThrows(ResponseStatusException.class,()->service.context("C1","200"));
row.setIncompleteScope(true);assertFalse(service.context("C1",null).canFollowup());
assertEquals("CONTAINER:C1",AppointmentContainerService.subjectKey("C1",null));
assertEquals("CONTAINER:C1:WAREHOUSE:100",AppointmentContainerService.subjectKey("C1","100"));
assertThrows(ResponseStatusException.class,()->AppointmentContainerService.subjectKey("C1:WAREHOUSE:100",null));
}
@Test void unavailableDirectoryDoesNotBecomeAnEmptyAuthoritativeResult() {
var mapper=mock(AppointmentContainerMapper.class);var warehouse=mock(AppointmentWarehouseClient.class);
when(warehouse.catalog(anyString())).thenThrow(new IllegalStateException());
var error=assertThrows(ResponseStatusException.class,()->new AppointmentContainerService(mapper,warehouse,"token").page(1,20,null));
assertEquals(503,error.getStatusCode().value());verifyNoInteractions(mapper);
}
}
@@ -38,7 +38,7 @@ class AppointmentFollowupPersistenceTest {
var sources=org.mockito.Mockito.mock(AppointmentSourceService.class);
org.mockito.Mockito.when(sources.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source(
"ORDER:1","A",1L,"IN-1",null,"CONTAINER","100","US warehouse","FBA:ONT8","ONT8",20,"ORDER:1",true,java.util.List.of()));
var service=new AppointmentFollowupService(sources,mapper,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
var service=new AppointmentFollowupService(sources,mapper,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),null);
var command=new AppointmentFollowupService.Save("FBA_RECEIVING",1,"timestamp-replay",null,"Test contact",null,null);
var saved=service.save("ORDER:1","A",command,"operator","Operator");
var replay=service.save("ORDER:1","A",command,"operator","Operator");
@@ -11,36 +11,72 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentFollowupServiceTest {
@Test void containerContactsShareOneSubjectAndReceivingContactsStayWarehouseScoped() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
var containers=mock(AppointmentContainerService.class);
when(containers.context("C1",null)).thenReturn(new AppointmentContainer.Context("C1","CONTAINER-A",null,null,true,List.of()));
when(containers.context("C1","100")).thenReturn(new AppointmentContainer.Context("C1","CONTAINER-A","100","美国仓",true,List.of()));
var saved=new HashMap<String,AppointmentFollowup>();
when(mapper.command(anyString(),eq("0"),anyString(),anyString())).thenAnswer(call -> saved.get(call.getArgument(0)+"/"+call.getArgument(3)));
when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> {
AppointmentFollowup row=call.getArgument(0); saved.put(row.getSourceKey()+"/"+row.getCommandKey(),row); return 1;
});
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules(),containers);
var pickup=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"pickup",null,"整柜统一联系",null,null);
var first=service.saveContainer("C1",null,pickup,"operator","运营");
assertEquals(first,service.saveContainer("C1",null,pickup,"operator","运营"));
service.saveContainer("C1","100",new AppointmentFollowupService.Save("WAREHOUSE_RECEIVING",0,"receiving",null,"联系接仓",null,null),"operator","运营");
assertEquals(2,saved.size());
assertNull(saved.get("CONTAINER:C1/pickup").getSourceOrderId());
assertEquals("0",saved.get("CONTAINER:C1:WAREHOUSE:100/receiving").getCustomerId());
verify(containers,times(1)).context("C1",null);
verifyNoInteractions(cargo);
assertThrows(ResponseStatusException.class,()->service.saveContainer("C1","100",pickup,"operator","运营"));
assertThrows(ResponseStatusException.class,()->service.save("ORDER:1","A",pickup,"operator","运营"));
assertThrows(ResponseStatusException.class,()->service.history("CONTAINER:C1","0",1,20));
verify(mapper,times(2)).insert(any(AppointmentFollowup.class));
}
@Test void uncertainContainerScopeBlocksNewRecords() {
var mapper=mock(AppointmentFollowupMapper.class); var containers=mock(AppointmentContainerService.class);
when(containers.context("C1",null)).thenReturn(new AppointmentContainer.Context("C1",null,null,null,false,List.of("范围待核对")));
var service=new AppointmentFollowupService(mock(AppointmentSourceService.class),mapper,new ObjectMapper(),containers);
var failure=assertThrows(ResponseStatusException.class,()->service.saveContainer("C1",null,
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"key",null,"跟进",null,null),"operator","运营"));
assertEquals(409,failure.getStatusCode().value());
verify(mapper,never()).insert(any(AppointmentFollowup.class));
}
@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.command(eq("ORDER:1"),eq("A"),eq("FBA_RECEIVING"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3)));
when(mapper.version("ORDER:1","A","FBA_RECEIVING")).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 service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules(),null);
var first=new AppointmentFollowupService.Save("FBA_RECEIVING",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","运营甲");
service.save("ORDER:1","A",new AppointmentFollowupService.Save("FBA_RECEIVING",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","运营甲"));
new AppointmentFollowupService.Save("FBA_RECEIVING",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","运营甲"));
new AppointmentFollowupService.Save("FBA_RECEIVING",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());
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper(),null);
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",
@@ -51,7 +87,7 @@ class AppointmentFollowupServiceTest {
@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 service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper(),null);
var history=service.history("ORDER:1","A",2,20);
assertEquals(21,history.history().total()); assertEquals(2,history.history().pages());
verifyNoInteractions(cargo);
@@ -16,6 +16,24 @@ 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 containerReadsAndWritesRequirePlatformIdentityAndSeparatePermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentContainerController.class);
var user=user(0L,"PLATFORM");
var request=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"key",null,"已联系",null,null);
login(LIST);
assertThrows(AccessDeniedException.class,()->controller.page(user,1,20,null));
assertThrows(AccessDeniedException.class,()->controller.context(user,"C1",null));
assertThrows(AccessDeniedException.class,()->controller.history(user,"C1",null,1,20));
login(LIST,CARGO);
controller.page(user,1,20,null);
assertThrows(AccessDeniedException.class,()->controller.save(user,"C1",null,request));
login(LIST,CARGO,WRITE);
assertThrows(ResponseStatusException.class,()->controller.save(user(1L,"CLIENT"),"C1",null,request));
controller.save(user,"C1",null,request);
verify(context.getBean(AppointmentFollowupService.class)).saveContainer("C1",null,request,"7","operator");
} finally { SecurityContextHolder.clearContext(); }
}
@Test void readRequiresBothSourcePermissionsAndCannotWrite() {
try (var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
@@ -47,6 +65,8 @@ class AppointmentPlanningAuthorizationTest {
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 AppointmentContainerService containers() { return mock(AppointmentContainerService.class); }
@Bean AppointmentContainerController containerController(AppointmentContainerService containers,AppointmentFollowupService followups) { return new AppointmentContainerController(containers,followups); }
@Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); }
@Bean AppointmentFollowupService followups() { return mock(AppointmentFollowupService.class); }
@Bean AppointmentSourceService source() { return mock(AppointmentSourceService.class); }