feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -4,11 +4,34 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
class InboundPlanSchemaContractTest {
@Test
void splitBatchAndVerificationDefinitionsMatchEveryDeploymentEntry() throws IOException {
String source=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(var file:List.of("../deploy/sql/海外区域统一增量-MySQL8.sql","../deploy/sql/reset-test-fba-appointments.sql")) {
String deployment=Files.readString(Path.of(file));
for(var name:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_appointment_cargo_verification")) {
assertEquals(definition(source,name),definition(deployment,name),file+" must preserve allocation and history for "+name);
}
}
}
private static String definition(String sql,String name) {
var matcher=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+Pattern.quote(name)+" \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertTrue(matcher.find(),name+" must exist");
String definition=matcher.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
assertFalse(matcher.find(),name+" must have one definition");
return definition;
}
@Test
void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) {
@@ -33,8 +56,11 @@ class InboundPlanSchemaContractTest {
assertTrue(appointment.contains("confirmation_no varchar(128) null"));
assertTrue(appointment.contains("planned_date date null"));
assertTrue(appointment.contains("appointment_start_utc datetime(3) null"));
assertTrue(schema.contains("unique key uk_ll_fba_active_source (active_source_key)"));
assertTrue(schema.contains("unique key uk_ll_fba_active_order (active_order_id)"));
assertFalse(schema.contains("unique key uk_ll_fba_active_source (active_source_key)"));
assertFalse(schema.contains("unique key uk_ll_fba_active_order (active_order_id)"));
assertTrue(schema.contains("key idx_ll_fba_active_source (customer_id,source_key,active)"));
assertTrue(schema.contains("key idx_ll_fba_active_order (customer_id,order_id,active)"));
assertTrue(schema.contains("replacement_keys_json longtext null"));
assertTrue(schema.contains("create table if not exists ll_fba_appointment_command"));
String items = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
assertTrue(items.substring(0, items.indexOf("engine=innodb")).contains("customer_id varchar(128) not null"));
@@ -26,7 +26,7 @@ class InboundPlanDispatchStatusPersistenceTest {
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,inbound_code VARCHAR(50),destination_warehouse_id VARCHAR(30),destination_warehouse_code VARCHAR(30),destination_warehouse_name VARCHAR(50),fba_warehouse_code VARCHAR(30),required_package_count INT,volume_cbm DECIMAL(18,3),planned_pallet_count INT)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),lx_code VARCHAR(50),order_type VARCHAR(30),overseas_warehouse_code VARCHAR(30),overseas_warehouse_name VARCHAR(50),customer_name VARCHAR(50))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,order_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20),fba_warehouse_code VARCHAR(30),active BOOLEAN)");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,replacement_nos_json VARCHAR(1000))");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT,picked_package_count INT)");
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))");
@@ -83,7 +83,7 @@ class InboundPlanDispatchStatusPersistenceTest {
db.update("INSERT INTO ll_inbound_plan VALUES(4,'LIP-2','B',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)");
db.update("INSERT INTO ll_inbound_plan_line VALUES(5,4,6,'IN-2','91','ABE4','ABE4','ABE4',1,1,1)");
db.update("INSERT INTO ll_order VALUES(6,'B','LX-2','FIRST_LEG','LA','洛杉矶仓','客户B')");
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP)");
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'APT-SHARED','SUCCESS',CURRENT_TIMESTAMP,NULL)");
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,3,7,'A','ABE4',TRUE),(9,6,7,'B','ABE4',TRUE)");
assertThat(mapper.rows(null, query(null), 0, 20)).hasSize(2)
.extracting(InboundPlanListResponse::appointmentNo).containsOnly("APT-SHARED");
@@ -95,6 +95,17 @@ class InboundPlanDispatchStatusPersistenceTest {
}
}
@Test void splitSourcesNeverExposeAnArbitraryLatestAppointmentAsTheWholeOrder() {
db.update("INSERT INTO ll_inbound_fba_appointment VALUES(7,CURRENT_TIMESTAMP,'AP-FIRST','SUCCESS',CURRENT_TIMESTAMP,NULL),(8,CURRENT_TIMESTAMP,'AP-SECOND','PENDING',NULL,NULL)");
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(9,3,7,'A','ABE4',TRUE),(10,3,8,'A','ABE4',TRUE)");
var row=mapper.rows("A",query(null),0,20).get(0);
assertThat(row.appointmentStatus()).isEqualTo("MULTIPLE");assertThat(row.appointmentNo()).isNull();assertThat(row.appointmentAt()).isNull();
db.update("UPDATE ll_inbound_fba_appointment_item SET active=FALSE WHERE id=10");
db.update("UPDATE ll_inbound_fba_appointment SET appointment_status='CANCELLED' WHERE id=8");
var remaining=mapper.rows("A",query(null),0,20).get(0);
assertThat(remaining.appointmentNo()).isEqualTo("AP-FIRST");assertThat(remaining.appointmentStatus()).isEqualTo("SUCCESS");
}
private void dispatch(long id, String owner, String status, String itemOwner) {
db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status);
db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id);
@@ -82,6 +82,7 @@ class ReceivingContainerServiceBehaviorTest {
@BeforeEach
void setUp() {
service = new ReceivingContainerService();
ReflectionTestUtils.setField(service,"workRouteGuard",org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard.class));
ReflectionTestUtils.setField(service, "plans", plans);
ReflectionTestUtils.setField(service, "lines", lines);
ReflectionTestUtils.setField(service, "pallets", pallets);
@@ -0,0 +1,112 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentCargoVerificationPersistenceTest {
private static AppointmentCargoVerificationService.Save save(int version,Integer quantity,String key,String... refs) {
return new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",version,quantity,List.of(refs),"按原票核实",key);
}
private static SourceFbaAppointmentRequest appointment(String key,FbaAppointmentDetails details) {
return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")),"100","ONT8",details,key);
}
private static FbaAppointmentDetails pending() {return new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null);}
private static FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(null,null,null,"EXT-1",LocalDateTime.of(2026,10,20,9,0),null,"SUCCESS",null,null,null);}
private static void flags(AnnotationConfigApplicationContext context) {
when(context.getBean(AppointmentBatchPreviewMapper.class).flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false)));
}
@Test void savesWithoutAppointmentOrConfirmationAndReplaysOriginalVersionAfterLaterEdits() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(AppointmentCargoVerificationService.class);
var db=new JdbcTemplate(context.getBean(DataSource.class));
var first=service.save("ORDER:1","A",save(0,10,"first","FBA-A"),"operator","客服");
assertEquals(1,first.version());assertEquals(List.of("FBA-A"),first.shipmentReferences());
service.save("ORDER:1","A",save(1,9,"second","FBA-A-NEW"),"operator","客服");
assertEquals(first,service.save("ORDER:1","A",save(0,10,"first","FBA-A"),"operator","客服"));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class));
assertThrows(ResponseStatusException.class,()->service.save("ORDER:1","A",save(0,10,"first","OTHER"),"operator","客服"));
assertThrows(ResponseStatusException.class,()->service.save("ORDER:1","A",save(0,10,"first","FBA-A"),"other-operator","客服"));
}
}
@Test void verificationEnforcesSourceLimitAndExistingAllocationAndSurvivesCancellation() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
flags(context);
var cargo=context.getBean(AppointmentCargoVerificationService.class);
var appointments=context.getBean(InboundFbaAppointmentService.class);
assertEquals(409,assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(0,11,"overflow"),"operator","客服")).getStatusCode().value());
cargo.save("ORDER:1","A",save(0,8,"verified","FBA-A"),"operator","客服");
var planned=appointments.createFromSources("operator",appointment("create",pending()));
assertEquals(8,planned.items().get(0).packageCount());
assertEquals(1,planned.items().get(0).cargoVerificationVersion());
assertEquals(List.of("FBA-A"),planned.items().get(0).shipmentReferences());
assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(1,7,"under-allocated","FBA-A"),"operator","客服"));
var confirmed=appointments.update("operator",planned.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),0,"confirm"));
assertEquals("SUCCESS",confirmed.appointmentStatus(),"独立核实资料应满足预约货量和货件门禁,无需改写原订单明细");
cargo.save("ORDER:1","A",save(1,8,"new-reference","FBA-RECHECKED"),"operator","客服");
assertEquals(List.of("FBA-A"),appointments.detail(planned.appointmentNo()).items().get(0).shipmentReferences(),"后续核实不能静默改变原预约快照");
appointments.delete("operator",planned.appointmentNo(),new CancelFbaAppointmentRequest(1,"对方取消"));
var db=new JdbcTemplate(context.getBean(DataSource.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class));
assertEquals(0,context.getBean(AppointmentCargoVerificationMapper.class).allocated("A","ORDER:1").packages());
cargo.save("ORDER:1","A",save(2,7,"after-cancel","FBA-RECHECKED"),"operator","客服");
}
}
@Test void incompleteVerificationCanBeSavedButCannotConfirmAndChangedSourceMustBeReverified() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
flags(context);
var cargo=context.getBean(AppointmentCargoVerificationService.class);
var appointments=context.getBean(InboundFbaAppointmentService.class);
cargo.save("ORDER:1","A",save(0,10,"only-quantity"),"operator","客服");
assertThrows(ResponseStatusException.class,()->appointments.createFromSources("operator",appointment("cannot-confirm",confirmed())));
var changed=new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",9,"ORDER:2",true,List.of());
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
assertThrows(ResponseStatusException.class,()->appointments.createFromSources("operator",appointment("changed",pending())));
assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(1,9,"stale-source","FBA-A"),"operator","客服"));
var request=new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:2",1,9,List.of("FBA-A"),null,"fresh-source");
cargo.save("ORDER:1","A",request,"operator","客服");
assertEquals(9,appointments.createFromSources("operator",appointment("fresh-create",pending())).items().get(0).packageCount());
}
}
@Test void simultaneousCommandsWithTheSameExpectedVersionCannotOverwriteEachOther() throws Exception {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(AppointmentCargoVerificationService.class);
var executor=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
var one=executor.submit(()->attempt(service,start,"one"));var two=executor.submit(()->attempt(service,start,"two"));start.countDown();
assertEquals(1,(one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0));
assertEquals(1,new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class));
} finally {executor.shutdownNow();}
}
}
@Test void declaredQuantityCanStayUnknownUntilIndependentVerificationWithoutChangingSource() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
flags(context);
var source=new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",null,"ORDER:1",true,List.of());
var resolver=context.getBean(AppointmentSourceService.class);
when(resolver.resolveBatch(anyList())).thenReturn(List.of(source));when(resolver.resolve("ORDER:1","A",null)).thenReturn(source);
var service=context.getBean(AppointmentCargoVerificationService.class);
service.save("ORDER:1","A",save(0,12,"fill-quantity","FBA-A"),"operator","客服");
var view=service.get("ORDER:1","A",null);
assertNull(view.source().packages());assertEquals(12,view.effectivePackages());assertEquals(12L,view.availablePackages());
assertEquals(List.of("VERIFY_CARGO"),view.allowedActions());
assertEquals(12,context.getBean(InboundFbaAppointmentService.class).createFromSources("operator",appointment("create",confirmed())).items().get(0).packageCount());
}
}
private static boolean attempt(AppointmentCargoVerificationService service,CountDownLatch start,String key) throws Exception {
start.await();try {service.save("ORDER:1","A",save(0,10,key,"FBA-A"),"operator","客服");return true;}
catch(ResponseStatusException error) {assertEquals(409,error.getStatusCode().value());return false;}
}
}
@@ -155,13 +155,12 @@ class FbaAppointmentPersistenceTest {
assertNull(service.cancellationHistory(original.appointmentNo(),1,20).records().get(0).cancelReason());
}
}
@Test void unknownQuantityOnReviewDoesNotSilentlyKeepThePreviousKnownQuantity() {
@Test void changedUnknownSourceQuantityBlocksReviewWithoutErasingTheAllocatedQuantity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var saved=service.createFromSources("operator",request("create"));
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(cargo(null));
var updated=service.update("operator",saved.appointmentNo(),new UpdateFbaAppointmentRequest(request("unused").details(),0,"review"));
assertNull(updated.items().get(1).packageCount());
assertNull(new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT package_count FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2'",Integer.class));
assertThrows(ResponseStatusException.class,()->service.update("operator",saved.appointmentNo(),new UpdateFbaAppointmentRequest(request("unused").details(),0,"review")));
assertEquals(20,new JdbcTemplate(context.getBean(DataSource.class)).queryForObject("SELECT package_count FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2'",Integer.class));
}
}
@Test void actualIncomingBindsTheOriginalBookingInItsOwnTransactionWithoutChangingPlannedFacts() {
@@ -194,13 +193,15 @@ class FbaAppointmentPersistenceTest {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:appointment-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
String baseline;try(var input=FbaAppointmentPersistenceTest.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_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command")) {
for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command","ll_appointment_cargo_verification")) {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");assertTrue(start>=0);
String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql);
}
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0)");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)");
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))");
String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
for(var sql:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql);
}return ds;
}
@Bean DataSourceTransactionManager transactionManager(DataSource dataSource){return new DataSourceTransactionManager(dataSource);}
@@ -208,6 +209,8 @@ class FbaAppointmentPersistenceTest {
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),dataSource));config.setMapUnderscoreToCamelCase(true);
config.addMapper(InboundFbaAppointmentMapper.class);config.addMapper(InboundFbaAppointmentItemMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);config.addMapper(FbaAppointmentCommandMapper.class);
config.addMapper(OrderVersionProbe.class);
config.addMapper(AppointmentCargoVerificationMapper.class);
config.addMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);
return new MybatisSqlSessionFactoryBuilder().build(config);
}
@Bean SqlSessionTemplate template(SqlSessionFactory factory){return new SqlSessionTemplate(factory);}
@@ -215,13 +218,19 @@ class FbaAppointmentPersistenceTest {
@Bean InboundFbaAppointmentItemMapper itemMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentItemMapper.class);}
@Bean InboundFbaAppointmentQueryMapper queryMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentQueryMapper.class);}
@Bean FbaAppointmentCommandMapper commands(SqlSessionTemplate session){return session.getMapper(FbaAppointmentCommandMapper.class);}
@Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenReturn(cargo(20));when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;}
@Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false),new AppointmentBatchPreviewMapper.Flags(2L,false,false,false)));return mock;}
@Bean AppointmentCargoVerificationMapper verifications(SqlSessionTemplate session){return session.getMapper(AppointmentCargoVerificationMapper.class);}
@Bean AppointmentCargoVerificationService verificationService(AppointmentSourceService sources,AppointmentCargoVerificationMapper mapper,ObjectMapper json){return new AppointmentCargoVerificationService(sources,mapper,json);}
@Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenAnswer(call->{List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);return refs.stream().map(ref->cargo(20).stream().filter(s->s.sourceKey().equals(ref.sourceKey()) && s.customerId().equals(ref.customerId())).findFirst().orElseThrow()).toList();});when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;}
@Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;}
@Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();}
@Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);}
@Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;}
@Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;}
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);}
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(mapper);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService workCommitments(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService(mapper,json);}
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes);}
}
}
@@ -0,0 +1,150 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FbaAppointmentRepartitionPersistenceTest {
private static SourceFbaAppointmentRequest create(String key) {
return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")),
"100","ONT8",pending(),key);
}
private static FbaAppointmentDetails pending(){return new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null);}
private static RepartitionFbaAppointmentsRequest.Allocation cargo(String customer,String key,int packages) {return new RepartitionFbaAppointmentsRequest.Allocation(customer,key,packages,"ORDER:1",null);}
private static RepartitionFbaAppointmentsRequest.Batch batch(RepartitionFbaAppointmentsRequest.Allocation... rows){return new RepartitionFbaAppointmentsRequest.Batch(null,null,List.of(rows));}
private static RepartitionFbaAppointmentsRequest split(FbaAppointmentResponse original,String key) {
return new RepartitionFbaAppointmentsRequest(List.of(new RepartitionFbaAppointmentsRequest.Original(original.appointmentNo(),original.version())),
List.of(batch(cargo("A","ORDER:1",4),cargo("B","ORDER:2",20)),batch(cargo("A","ORDER:1",6))),"按收货窗口分批",key);
}
@Test void splitAndMergeConserveEveryOriginalSourceAndReschedulingNeverExpandsBackToWholeOrder() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class));
var original=service.createFromSources("operator",create("create"));
assertEquals("FBA_APPOINTMENT:"+original.id(),original.batchKey());
assertTrue(service.batchContext(original.batchKey()).allowedActions().containsAll(List.of("SPLIT","MERGE")));
var split=service.repartition("operator",split(original,"split"));
assertEquals(split,service.repartition("operator",split(original,"split")));
assertEquals(2,split.appointments().size());assertEquals(2,service.unscheduled(1,20).total());
var historical=service.detail(original.appointmentNo());
assertEquals(split.appointments().stream().map(FbaAppointmentResponse::appointmentNo).toList(),historical.replacementAppointmentNos());
assertEquals(split.appointments().stream().map(FbaAppointmentResponse::batchKey).toList(),historical.replacementBatchKeys());
assertEquals(List.of(original.batchKey()),split.replacedBatchKeys());
assertTrue(service.batchContext(original.batchKey()).allowedActions().isEmpty());
assertEquals(3,service.relatedBatchSnapshots(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).size());
assertTrue(service.relatedBatchSnapshots(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).isEmpty());
assertTrue(service.relatedBatchSnapshots(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))).isEmpty());
assertEquals(List.of(10,20),historical.items().stream().map(FbaAppointmentResponse.Item::packageCount).toList());
assertEquals("按收货窗口分批",historical.repartitionReason());
assertThrows(ResponseStatusException.class,()->service.update("operator",historical.appointmentNo(),new UpdateFbaAppointmentRequest(pending(),historical.version(),"old-edit")));
assertThrows(ResponseStatusException.class,()->service.delete("operator",historical.appointmentNo(),new CancelFbaAppointmentRequest(historical.version(),"old-cancel")));
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",create("duplicate")));
var first=split.appointments().get(0);var second=split.appointments().get(1);
var rescheduled=service.update("operator",second.appointmentNo(),new UpdateFbaAppointmentRequest(new FbaAppointmentDetails(LocalDate.of(2026,10,20),null,null,null,null,null,"PENDING",null,null,null),0,"reschedule"));
assertEquals(6,rescheduled.items().get(0).packageCount());
var merged=service.repartition("operator",new RepartitionFbaAppointmentsRequest(List.of(
new RepartitionFbaAppointmentsRequest.Original(first.appointmentNo(),first.version()),
new RepartitionFbaAppointmentsRequest.Original(second.appointmentNo(),rescheduled.version())),
List.of(batch(cargo("A","ORDER:1",10),cargo("B","ORDER:2",20))),"同仓合约","merge"));
assertEquals(1,service.unscheduled(1,20).total());assertEquals(2,merged.appointments().get(0).items().size());
assertEquals(10,db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:1' AND active=1",Integer.class));
assertEquals(20,db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE source_key='ORDER:2' AND active=1",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Integer.class));
assertEquals(0,db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class));
}
}
@Test void staleSourceOrVerificationIntentCannotConsumeOrReplaceCurrentBatch() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
var verification=context.getBean(AppointmentCargoVerificationService.class);
var original=service.createFromSources("operator",create("create"));
var stale=split(original,"stale-verification");
verification.save("ORDER:1","A",new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,10,List.of("FBA-A"),null,"verify"),"operator","客服");
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.repartition("operator",stale)).getStatusCode().value());
var contextAfterVerification=service.batchContext(original.batchKey());
assertEquals(1,contextAfterVerification.sources().stream().filter(s->s.sourceKey().equals("ORDER:1")).findFirst().orElseThrow().cargoVerificationVersion());
var badSource=new RepartitionFbaAppointmentsRequest(stale.originals(),List.of(
batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",4,"ORDER:0",1),cargo("B","ORDER:2",20)),
batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",6,"ORDER:1",1))),"来源已变化","stale-source");
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.repartition("operator",badSource)).getStatusCode().value());
var fresh=new RepartitionFbaAppointmentsRequest(stale.originals(),List.of(
batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",4,"ORDER:1",1),cargo("B","ORDER:2",20)),
batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",6,"ORDER:1",1))),"刷新后分批","fresh");
assertTrue(service.detail(original.appointmentNo()).replacementBatchKeys().isEmpty());
assertEquals(2,service.repartition("operator",fresh).appointments().size());
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.batchContext("FBA_APPOINTMENT:0" )).getStatusCode().value());
}
}
@Test void matchingGrandTotalCannotMoveQuantityAcrossCustomersOrOriginalSources() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var original=service.createFromSources("operator",create("create"));
var input=split(original,"bad");
var wrong=new RepartitionFbaAppointmentsRequest(input.originals(),List.of(batch(cargo("A","ORDER:1",5)),batch(cargo("B","ORDER:2",25))),"错误分配","wrong-count");
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.repartition("operator",wrong)).getStatusCode().value());
var customer=new RepartitionFbaAppointmentsRequest(input.originals(),List.of(batch(cargo("B","ORDER:1",10)),batch(cargo("A","ORDER:2",20))),"错误客户","wrong-customer");
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.repartition("operator",customer)).getStatusCode().value());
assertTrue(service.detail(original.appointmentNo()).replacementAppointmentNos().isEmpty());
assertEquals(1,service.unscheduled(1,20).total());
}
}
@Test void anyInsertFailureRollsBackOldClaimsNewBatchesAndIdempotencyRecordTogether() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class));
var original=service.createFromSources("operator",create("create"));
db.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD CONSTRAINT reject_second CHECK(package_count<>6)");
assertThrows(RuntimeException.class,()->service.repartition("operator",split(original,"retry")));
assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE operation='REPARTITION'",Integer.class));
assertTrue(service.detail(original.appointmentNo()).replacementAppointmentNos().isEmpty());
db.execute("ALTER TABLE ll_inbound_fba_appointment_item DROP CONSTRAINT reject_second");
assertEquals(2,service.repartition("operator",split(original,"retry")).appointments().size());
}
}
@Test void unknownQuantityCanBeVerifiedThenSplitImmediatelyWithoutConfirmingOrRewritingOriginalOrder() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var source=new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",null,"ORDER:1",true,List.of());
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(source));
var service=context.getBean(InboundFbaAppointmentService.class);
var original=service.createFromSources("operator",new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")),"100","ONT8",pending(),"create"));
var request=new RepartitionFbaAppointmentsRequest(List.of(new RepartitionFbaAppointmentsRequest.Original(original.appointmentNo(),0)),
List.of(batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",5,"ORDER:1",1)),batch(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",7,"ORDER:1",1))),"分两次送仓","split");
assertThrows(ResponseStatusException.class,()->service.repartition("operator",request));
context.getBean(AppointmentCargoVerificationService.class).save("ORDER:1","A",new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,12,List.of(),null,"verify"),"operator","客服");
var editable=service.batchContext(original.batchKey());
assertTrue(editable.allowedActions().contains("SPLIT"));assertEquals(12,editable.sources().get(0).packageCount());
var result=service.repartition("operator",request);
assertEquals(List.of(5,7),result.appointments().stream().map(a->a.items().get(0).packageCount()).toList());
assertTrue(result.appointments().stream().allMatch(a->a.appointmentAt()==null && "PENDING".equals(a.appointmentStatus())));
assertNull(service.detail(original.appointmentNo()).items().get(0).packageCount());
}
}
@Test void existingTransportAndConcurrentRepartitionCannotDuplicateWork() throws Exception {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class));
var original=service.createFromSources("operator",create("create"));
db.update("INSERT INTO ll_dispatch_order VALUES(10,'A','DRAFT')");db.update("INSERT INTO ll_dispatch_item VALUES(1,'A',10)");
db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,11,12,?,'A','ORDER:1','FBA:ONT8',1)",original.batchKey());
assertThrows(ResponseStatusException.class,()->service.repartition("operator",split(original,"blocked")));
db.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE id=10");
var executor=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1);
try {
var one=executor.submit(()->attempt(service,start,split(original,"one")));var two=executor.submit(()->attempt(service,start,split(original,"two")));start.countDown();
assertEquals(1,(one.get(10,TimeUnit.SECONDS)?1:0)+(two.get(10,TimeUnit.SECONDS)?1:0));
assertEquals(2,service.unscheduled(1,20).total());
} finally {executor.shutdownNow();}
}
}
private static boolean attempt(InboundFbaAppointmentService service,CountDownLatch start,RepartitionFbaAppointmentsRequest request) throws Exception {
start.await();try {service.repartition("operator",request);return true;}catch(ResponseStatusException error){assertEquals(409,error.getStatusCode().value());return false;}
}
}
@@ -33,6 +33,8 @@ class InboundFbaAppointmentServiceTest {
headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class);
commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class);
service=new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class));
ReflectionTestUtils.setField(service,"verification",mock(AppointmentCargoVerificationService.class));
ReflectionTestUtils.setField(service,"appointmentMapper",headers);ReflectionTestUtils.setField(service,"itemMapper",items);ReflectionTestUtils.setField(service,"queryMapper",query);
ReflectionTestUtils.setField(service,"commands",commands);ReflectionTestUtils.setField(service,"sources",sources);ReflectionTestUtils.setField(service,"flags",flags);ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
when(commands.find(anyString())).thenAnswer(c->journal.get(c.getArgument(0)));
@@ -41,6 +43,7 @@ class InboundFbaAppointmentServiceTest {
when(sources.resolveBatch(anyList())).thenAnswer(c->{List<AppointmentCoverageService.SourceRef> refs=c.getArgument(0);return refs.stream().map(r->cargo.get(r.sourceKey())).toList();});
when(sources.destinationTimezone(anyString())).thenReturn("America/Los_Angeles");
when(query.lockOrders(anyList())).thenAnswer(c->c.getArgument(0));
when(query.allocation(anyString(),anyString(),nullable(Long.class),nullable(Long.class))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(0,0,0));
when(flags.flags(anyList())).thenAnswer(c->{List<Long> ids=c.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});
when(headers.insert(any(InboundFbaAppointment.class))).thenAnswer(c->{storedHeaders.add(c.getArgument(0));return 1;});
when(headers.selectOne(any())).thenAnswer(c->storedHeaders.isEmpty()?null:storedHeaders.get(0));
@@ -148,9 +151,9 @@ class InboundFbaAppointmentServiceTest {
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("dst-gap",gap,"ORDER:1")));
}
@Test void duplicateActiveSourceOrDatabaseClaimRaceCannotCreateAnotherAppointment() {
when(query.countActiveSources(anyList(),anyList(),isNull())).thenReturn(1L);
assertEquals(409,assertThrows(ResponseStatusException.class,()->create("occupied")).getStatusCode().value());assertTrue(storedHeaders.isEmpty());
when(query.countActiveSources(anyList(),anyList(),isNull())).thenReturn(0L);
when(query.allocation(anyString(),anyString(),nullable(Long.class),nullable(Long.class))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(20,1,0));
assertEquals(409,assertThrows(ResponseStatusException.class,()->create("occupied")).getStatusCode().value());assertTrue(storedItems.isEmpty());
when(query.allocation(anyString(),anyString(),nullable(Long.class),nullable(Long.class))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(0,0,0));
when(items.insert(any(InboundFbaAppointmentItem.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("claimed"));
assertEquals(409,assertThrows(ResponseStatusException.class,()->create("raced")).getStatusCode().value());
}
@@ -183,7 +186,7 @@ class InboundFbaAppointmentServiceTest {
service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段"));
assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertEquals(at,storedHeaders.get(0).getAppointmentAt());assertFalse(storedItems.get(0).getActive());
assertEquals(result,service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1")));
when(query.countActiveSources(anyList(),anyList(),eq(result.id()))).thenReturn(1L);
when(query.allocation(anyString(),anyString(),nullable(Long.class),eq(result.id()))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(10,1,0));
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),1,"restore")));
}
@Test void snowflakeLineSelectorsDeserializeWithoutPrecisionLoss() throws Exception {
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDeliveryProviderTest {
@Test void privateCompletionCoversThisDispatchPerSourceWithoutCompletingTheWholeOrder() {
var appointments=mock(InboundFbaAppointmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),mock(AppointmentWorkCommitmentService.class),deliveries,sources);
when(sources.privateBatch("DISPATCH:20")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",11,"C","ORDER:11","CUSTOMER:11",4),new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",12,"C","ORDER:12","CUSTOMER:12",6)));
var time=LocalDateTime.parse("2026-10-15T11:00:00");
var one=new AppointmentWorkDeliveryMapper.CustomerDelivery(11,"C",4,"L-1","DELIVERED",10,10,time,time.minusDays(1));
var two=new AppointmentWorkDeliveryMapper.CustomerDelivery(12,"C",6,"L-1","DELIVERED",10,10,time,time.minusDays(1));
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","DISPATCH:20","DISPATCH:20",null,List.of("DISPATCH:20"),List.of(),List.of());
when(deliveries.privateBatch("DISPATCH:20")).thenReturn(List.of(one));
assertNotEquals(ExecutionStatus.COMPLETED,provider.read(List.of(request)).get(0).executionStatus());
when(deliveries.privateBatch("DISPATCH:20")).thenReturn(List.of(one,two));
var fact=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.COMPLETED,fact.executionStatus());assertEquals(time,fact.actual().localOccurredAt());assertEquals("D-20",fact.businessNo());
when(sources.privateBatch("DISPATCH:20")).thenReturn(List.of());
var removed=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.PENDING,removed.executionStatus());assertNull(removed.actual());assertFalse(removed.blockReasons().isEmpty());
verify(deliveries,never()).customer(org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.anyString());verifyNoInteractions(appointments);
}
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class AppointmentWorkDispatchBindingTest {
@Test void groupedReplayCannotRewriteBatchBindingsAfterTheOriginalDispatchHasMovedOn() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes);
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
when(mapper.byDispatch(20)).thenReturn(List.of(row));
var allocation=new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
assertDoesNotThrow(()->service.requireReplay("C",20,List.of(allocation)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireReplay("C",20,List.of(new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:2","C","ORDER:1","FBA:ONT8",4))));
verify(mapper,never()).remove(anyLong());verify(mapper,never()).insert(any());verifyNoInteractions(changes);
}
@Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes);
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of());
var expected=new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4);
verify(mapper).insert(expected);verify(changes).requirementsChanged();
when(mapper.bindings("D-20")).thenReturn(List.of(expected));
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,3)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,4)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));verifyNoInteractions(appointments);
}
@Test void repeatedPhysicalRowsForSameOrderResolveByIdentityWithoutIndexingDistinctResults() {
var sources=mock(AppointmentSourceService.class);
var one=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:A",4,4);
var two=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:B",5,5);
when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("C",1L))))
.thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:X",null)));
var refs=AppointmentWorkPhysicalSources.canonical(List.of(one,two),sources);
assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,one).sourceKey());
assertEquals("BOOKING:X",AppointmentWorkPhysicalSources.ref(refs,two).sourceKey());
}
@Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() {
var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes);
var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4);
when(mapper.physical(10,"C")).thenReturn(List.of(part));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
var batch=mock(FbaAppointmentResponse.class);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()));
when(mapper.bindings("D-1")).thenReturn(List.of());
when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
}
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripMapper;
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.util.*;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkDomainMapperTest {
@Test void originalDomainJoinsExecuteAgainstTheCurrentSchemaWithoutInventedColumns() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:work-domain-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
String baseline;try(var stream=getClass().getResourceAsStream("/db/lastleg.sql")){baseline=new String(Objects.requireNonNull(stream).readAllBytes(),StandardCharsets.UTF_8);}
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
var created=new HashSet<String>();
for(var table:List.of("ll_order","ll_inbound_plan","ll_inbound_plan_line","ll_inbound_plan_pallet","ll_inbound_plan_pallet_allocation","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_dispatch_pallet_lock","ll_dispatch_order","ll_dispatch_item","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_receiving_container_session","ll_delivery_order","ll_fleet_driver","ll_vehicle","ll_fleet_trip","ll_fleet_trip_dispatch")) {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");if(start<0) start=baseline.indexOf("CREATE TABLE "+table+" (");assertTrue(start>=0,table);
sql.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";"));
created.add(table);
}
// The current initialization script defines some fields in guarded ADD COLUMN literals.
// Execute those original definitions too; do not invent test-only business columns.
var additions=java.util.regex.Pattern.compile("'(ALTER TABLE (\\w+) ADD COLUMN (?:''|[^'])*)'").matcher(baseline);
while(additions.find()) if(created.contains(additions.group(2))) {
var prefix="ALTER TABLE "+additions.group(2)+" ";
for(var addition:additions.group(1).substring(prefix.length()).replace("''","'").split(",\\s*(?=ADD COLUMN)"))
sql.execute(prefix+addition.replace("ADD COLUMN ","ADD COLUMN IF NOT EXISTS "));
}
String work;try(var stream=getClass().getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(stream).readAllBytes(),StandardCharsets.UTF_8);}
for(var statement:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!statement.isBlank()) sql.execute(statement);
}
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
for(var mapper:List.of(AppointmentWorkDispatchMapper.class,AppointmentWorkDeliveryMapper.class,AppointmentWorkSourceMapper.class,AppointmentWorkReceivingMapper.class,AppointmentWorkTripMapper.class)) config.addMapper(mapper);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var dispatch=session.getMapper(AppointmentWorkDispatchMapper.class);
assertTrue(dispatch.candidates(1).isEmpty());assertTrue(dispatch.physical(1,"C").isEmpty());assertTrue(dispatch.pickedPhysical(1,"C").isEmpty());
assertTrue(dispatch.dispatchPhysical("D").isEmpty());assertTrue(dispatch.bindings("D").isEmpty());assertTrue(dispatch.byDispatch(1).isEmpty());assertTrue(dispatch.trips("FBA_APPOINTMENT:1").isEmpty());
assertEquals(0,dispatch.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",1));
var deliveries=session.getMapper(AppointmentWorkDeliveryMapper.class);assertTrue(deliveries.batch("FBA_APPOINTMENT:1").isEmpty());assertTrue(deliveries.customer("1","1").isEmpty());
assertTrue(deliveries.privateBatch("DISPATCH:1").isEmpty());
var sources=session.getMapper(AppointmentWorkSourceMapper.class);assertTrue(sources.containerSources("1").isEmpty());assertTrue(sources.privateDispatches("1").isEmpty());assertTrue(sources.privateBatch("DISPATCH:1").isEmpty());
var receiving=session.getMapper(AppointmentWorkReceivingMapper.class);assertTrue(receiving.plans("1").isEmpty());assertNull(receiving.session("1"));
var trips=session.getMapper(AppointmentWorkTripMapper.class);assertTrue(trips.batchTrips("FBA_APPOINTMENT:1").isEmpty());assertTrue(trips.batches(1).isEmpty());assertNull(trips.trip(1));
}
}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkReceivingGuardTest {
@Test void confirmedLocalDirectRouteRejectsReceivingBeforeRemoteFactsExist() {
var firstleg=mock(AppointmentWorkFirstlegClient.class); var routes=mock(AppointmentWorkRouteService.class);
when(routes.route("1")).thenReturn(new AppointmentWorkRouteService.Route(Flow.DIRECT));
var guard=new AppointmentWorkReceivingGuard(firstleg,routes,"internal-token");
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->guard.requireWarehouseFlow("1"));
verifyNoInteractions(firstleg);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkReceivingProviderTest {
@Test void receivingIsInProgressWithoutInventingAnActualCompletionAndTargetCarriesRealIdentity() {
var mapper=mock(AppointmentWorkReceivingMapper.class); var provider=new AppointmentWorkReceivingProvider(mapper);
var plan=new InboundPlan(); plan.setId(10L);plan.setPlanNo("IP-REAL");plan.setCustomerId("C");plan.setPlannedArrivalDate(LocalDate.of(2026,10,16));
var session=new ReceivingContainerSession();session.setId(2L);session.setStatus("RECEIVING");session.setReceivingAt(LocalDateTime.of(2026,10,15,10,0));
when(mapper.plans("1")).thenReturn(List.of(plan)); when(mapper.session("1")).thenReturn(session);
var request=new FactRequest("work",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,"1",null,null,null,List.of(),List.of(),List.of());
var fact=provider.read(List.of(request)).get(0);
assertEquals(ExecutionStatus.IN_PROGRESS,fact.executionStatus()); assertNull(fact.actual());
assertEquals(new ReceiptTarget("10","IP-REAL","C","1"),fact.action().target());
session.setStatus("COMPLETED"); session.setCompletedAt(LocalDateTime.of(2026,10,16,12,0));
fact=provider.read(List.of(request)).get(0); assertEquals(ExecutionStatus.COMPLETED,fact.executionStatus()); assertEquals(session.getCompletedAt(),fact.actual().localOccurredAt());
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
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.Mockito.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRequirementProviderTest {
@Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of());
var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10)));
var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4);
when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch));
var manifest=provider.requirements(container,Flow.TRANSFER);
assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList());
assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey());
when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4)));
var restored=provider.requirements(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys());
verifyNoInteractions(sources,cargo,appointments);
}
@Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() {
var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement));
assertEquals(List.of(requirement),provider.requirements(source,Flow.DIRECT));
verifyNoInteractions(sources,cargo,appointments,mapping);
}
}