feat(appointments): track forecast impacts and handling history

This commit is contained in:
2026-10-10 15:33:13 +08:00
parent 331f750871
commit 0ac2b849c3
18 changed files with 1323 additions and 0 deletions
@@ -0,0 +1,38 @@
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.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.access.AccessDeniedException;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentImpactAuthorizationTest {
@Test void readDoesNotGrantCommandsOrFleetDetailsAndCustomerCannotRead() {
try(var c=new AnnotationConfigApplicationContext(Config.class)) {
var controller=c.getBean(AppointmentImpactController.class);var service=c.getBean(AppointmentImpactService.class);var queries=c.getBean(AppointmentImpactQueryService.class);
var permissions=List.of("platform:appointment:list","platform:overseas-warehouse:last-mile-order:list");
var platform=new AuthenticatedUser(1L,0L,0L,1L,"staff","PLATFORM","WEB",Set.copyOf(permissions));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform,"unused",permissions.stream().map(SimpleGrantedAuthority::new).toList()));
controller.detail(platform,"1",1,20);verify(queries).detail("1",1,20,false);
assertThatThrownBy(()->controller.refresh(platform,new AppointmentImpactController.Refresh(1,20,null))).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->controller.handle(platform,"1",null)).isInstanceOf(AccessDeniedException.class);verifyNoInteractions(service);
var customer=new AuthenticatedUser(1L,9L,9L,1L,"customer","MERCHANT","WEB",Set.copyOf(permissions));
assertThatThrownBy(()->controller.page(customer,1,20,null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
var full=new ArrayList<>(permissions);full.add("platform:appointment:followup");full.add("platform:fleet:trip:list");
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform,"unused",full.stream().map(SimpleGrantedAuthority::new).toList()));
controller.refresh(platform,new AppointmentImpactController.Refresh(1,20,null));verify(service).refreshPage(1,20,null,"1","staff");
controller.detail(platform,"1",1,20);verify(queries).detail("1",1,20,true);
} finally {SecurityContextHolder.clearContext();}
}
@Configuration @EnableMethodSecurity static class Config {
@Bean AppointmentImpactService service(){return mock(AppointmentImpactService.class);}
@Bean AppointmentImpactQueryService queries(){return mock(AppointmentImpactQueryService.class);}
@Bean AppointmentImpactController controller(AppointmentImpactService service,AppointmentImpactQueryService queries){return new AppointmentImpactController(service,queries);}
}
}
@@ -0,0 +1,96 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.*;
import com.baiyue.common.core.api.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentImpactPersistenceTest {
static AppointmentForecastClient.Fact fact(long revision,int day) {
return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null);
}
static void observe(AnnotationConfigApplicationContext c,long revision,int day) {
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED"))));
}
static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) {
return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null);
}
@Test void stableWorkItemRetainsBaselineRejectsStaleClosureAndReopensAfterResolution() {
try(var c=new AnnotationConfigApplicationContext(Config.class)) {
var service=c.getBean(AppointmentImpactService.class);var db=new JdbcTemplate(c.getBean(DataSource.class));
observe(c,2,16);var initial=service.refresh("1","A","甲");
assertThat(initial.status()).isEqualTo("OPEN");assertThat(initial.baseline()).isEqualTo(fact(1,15));
assertThat(service.refresh("1","A","甲")).isEqualTo(initial);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_appointment_impact_history",Integer.class)).isEqualTo(1);
var owned=service.handle("1","A","甲",command(initial,"claim","CLAIM"));
assertThatThrownBy(()->service.handle("1","B","乙",command(owned,"other","CLAIM"))).hasMessageContaining("其他人员");
observe(c,3,17);
assertThatThrownBy(()->service.handle("1","A","甲",command(owned,"stale","RESOLVE"))).hasMessageContaining("新观察");
var changed=service.refresh("1","A","甲");assertThat(changed.baseline()).isEqualTo(initial.baseline());
assertThat(changed.impactNo()).isEqualTo(initial.impactNo());
var request=command(changed,"resolve","RESOLVE");var resolved=service.handle("1","A","甲",request);
assertThat(resolved.status()).isEqualTo("RESOLVED");
observe(c,4,18);var reopened=service.refresh("1","A","甲");
assertThat(reopened.status()).isEqualTo("OPEN");assertThat(reopened.baseline()).isEqualTo(fact(3,17));
when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenThrow(new IllegalStateException("offline"));
assertThat(service.handle("1","A","甲",request)).isEqualTo(resolved);
assertThatThrownBy(()->service.refresh("1","A","甲")).hasMessageContaining("来源暂不可用");
assertThat(service.detail("1")).isEqualTo(reopened);
}
}
@Test void historyFailureRollsBackStateAndConcurrentClaimsHaveOneWinner() throws Exception {
try(var c=new AnnotationConfigApplicationContext(Config.class)) {
var service=c.getBean(AppointmentImpactService.class);observe(c,2,16);var initial=service.refresh("1","A","甲");
var db=new JdbcTemplate(c.getBean(DataSource.class));
db.execute("ALTER TABLE ll_appointment_impact_history ADD CONSTRAINT fail_claim CHECK(action<>'CLAIM')");
assertThatThrownBy(()->service.handle("1","A","甲",command(initial,"retry","CLAIM"))).isInstanceOf(RuntimeException.class);
assertThat(service.detail("1")).isEqualTo(initial);
db.execute("ALTER TABLE ll_appointment_impact_history DROP CONSTRAINT fail_claim");
var executor=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
var futures=new ArrayList<Future<Boolean>>();
for(var operator:List.of("A","B")) futures.add(executor.submit(()->{
start.await();try{service.handle("1",operator,operator,command(initial,"claim","CLAIM"));return true;}
catch(org.springframework.web.server.ResponseStatusException error){assertThat(error.getStatusCode().value()).isEqualTo(409);return false;}
}));
start.countDown();int winners=0;for(var future:futures) if(future.get(10,TimeUnit.SECONDS)) winners++;
assertThat(winners).isEqualTo(1);assertThat(service.detail("1").version()).isEqualTo(initial.version()+1);
} finally {executor.shutdownNow();}
}
}
@Configuration @EnableTransactionManagement static class Config {
@Bean DataSource dataSource() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
String baseline;try(var input=AppointmentImpactPersistenceTest.class.getResourceAsStream("/db/lastleg.sql")){baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
try(var c=ds.getConnection();var statement=c.createStatement()) {
for(var table:List.of("ll_appointment_impact","ll_appointment_impact_history")) {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");
statement.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replaceAll(" ENGINE=InnoDB[^;]*;",";"));
}
}return ds;
}
@Bean DataSourceTransactionManager transactionManager(DataSource ds){return new DataSourceTransactionManager(ds);}
@Bean SqlSessionFactory sessionFactory(DataSource ds){var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentImpactMapper.class);return new MybatisSqlSessionFactoryBuilder().build(config);}
@Bean AppointmentImpactMapper mapper(SqlSessionFactory factory){return new SqlSessionTemplate(factory).getMapper(AppointmentImpactMapper.class);}
@Bean AppointmentForecastClient client(){return mock(AppointmentForecastClient.class);}
@Bean AppointmentContainerService containers(){var mock=mock(AppointmentContainerService.class);when(mock.context("1",null)).thenReturn(new AppointmentContainer.Context("1","CONT",null,null,true,List.of()));return mock;}
@Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();}
@Bean AppointmentImpactService service(AppointmentImpactMapper mapper,AppointmentForecastClient client,AppointmentContainerService containers,ObjectMapper json){return new AppointmentImpactService(mapper,client,containers,json,"internal");}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.*;
import com.baiyue.lastleg.fleet.business.*;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
class AppointmentImpactRelationTest {
@Test void containerLinksRespectCustomerPairsAndDistinguishSameTripExternalCommitments() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try(var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,order_type VARCHAR,current_status VARCHAR,inbound_code VARCHAR,customer_name VARCHAR,package_count INT,fba_warehouse_code VARCHAR)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,active INT,appointment_id BIGINT)");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR,appointment_status VARCHAR,fba_warehouse_code VARCHAR,appointment_at TIMESTAMP,timezone VARCHAR)");
s.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)");
s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,dispatch_no VARCHAR,status VARCHAR)");
s.execute("CREATE TABLE ll_fleet_trip_dispatch(customer_id VARCHAR,dispatch_no VARCHAR,trip_id BIGINT)");
s.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR,status VARCHAR,planned_departure_at TIMESTAMP)");
s.execute("CREATE TABLE ll_fleet_external_booking(trip_no VARCHAR,booking_no VARCHAR,customer_name VARCHAR,destination_label VARCHAR,status VARCHAR)");
s.execute("INSERT INTO ll_order VALUES(1,'A','10','FIRST_LEG','PENDING','IN-A','Alpha',10,'ONT8'),(2,'B','20','FIRST_LEG','PENDING','IN-B','Beta',20,'ONT8')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1','SUCCESS','ONT8','2026-10-15','America/Los_Angeles'),(2,'AP-WRONG','SUCCESS','ONT8','2026-10-15','America/Los_Angeles')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',1,1),(1,'B',1,2)");
s.execute("INSERT INTO ll_dispatch_order VALUES(1,'A','DS-1','PENDING'),(2,'B','DS-WRONG','PENDING')");
s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2)");
s.execute("INSERT INTO ll_fleet_trip VALUES(1,'TR-1','PLANNED','2026-10-15'),(2,'TR-WRONG','PLANNED','2026-10-15')");
s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES('A','DS-1',1),('B','DS-1',2)");
s.execute("INSERT INTO ll_fleet_external_booking VALUES('TR-1','FB-1','Other customer','LAX9','CONFIRMED'),('TR-1','FB-CANCEL','Other','ONT8','CANCELLED'),('TR-WRONG','FB-WRONG','Other','ONT8','CONFIRMED')");
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(AppointmentImpactRelationMapper.class);config.addMapper(FleetAppointmentImpactMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var relations=session.getMapper(AppointmentImpactRelationMapper.class);
assertThat(relations.cargo("10")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-A");
assertThat(relations.appointments("10")).extracting(AppointmentImpactRelationMapper.Appointment::appointmentNo).containsExactly("AP-1");
var links=new FleetAppointmentImpactAdapter(session.getMapper(FleetAppointmentImpactMapper.class)).related(relations.dispatches("10"));
assertThat(links.trips()).extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1");
assertThat(links.sameTripBookings()).extracting(AppointmentImpactTransportGateway.Booking::bookingNo).containsExactly("FB-1");
assertThat(relations.cargo("20")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-B");
}
}
}