feat(lastleg): connect appointment followups and fleet intake
This commit is contained in:
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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); }
|
||||
}
|
||||
}
|
||||
+86
@@ -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));
|
||||
}
|
||||
}
|
||||
+40
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user