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
@@ -17,11 +17,11 @@ class AppointmentBatchPreviewMapperTest {
s.execute("CREATE TABLE ll_order(id BIGINT, customer_id VARCHAR(32),detained BOOLEAN,fba_warehouse_code VARCHAR(32))");
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(32),destination_warehouse_code VARCHAR(32))");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR(32))");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(32),appointment_id BIGINT)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(32),appointment_id BIGINT,active BOOLEAN)");
s.execute("INSERT INTO ll_order VALUES(1,'A',FALSE,'ONT8'),(2,'B',TRUE,'ONT8'),(3,'C',FALSE,'ONT8')");
s.execute("INSERT INTO ll_order_detail VALUES(1,'OTHER','LAX9'),(2,'B','ONT8'),(3,'C','LAX9')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(10,'CANCELLED'),(11,'SUCCESS')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',10),(1,'OTHER',11),(3,'C',11)");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',10,TRUE),(1,'OTHER',11,TRUE),(3,'C',11,TRUE)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentBatchPreviewMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
@@ -7,6 +7,21 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentBatchPreviewServiceTest {
private static AppointmentCargoVerificationService verification() {
var service=mock(AppointmentCargoVerificationService.class);
when(service.inspect(any())).thenAnswer(call->{AppointmentSourceService.Source source=call.getArgument(0);return new AppointmentCargoVerificationService.Context(source,null,0,0,source.packages(),source.packages()==null?null:source.packages().longValue(),false,List.of("VERIFY_CARGO"),List.of());});
return service;
}
@Test void previewUsesVerifiedRemainingQuantityAndDoesNotRejectAnEntirePartiallyAllocatedSource() {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);var verification=verification();
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));var source=source("ORDER:1","A",1L,"100","FBA:ONT8");
when(sources.resolveBatch(refs)).thenReturn(List.of(source));when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true)));
when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,6,0,10,4L,false,List.of("VERIFY_CARGO"),List.of()));
var service=new AppointmentBatchPreviewService(sources,mapper,verification);
assertEquals(4,service.preview(refs).group().packages());
when(verification.inspect(source)).thenReturn(new AppointmentCargoVerificationService.Context(source,null,10,0,10,0L,false,List.of("VERIFY_CARGO"),List.of()));
assertThrows(ResponseStatusException.class,()->service.preview(refs));
}
private static AppointmentSourceService.Source source(String key,String customer,Long orderId,String origin,String destination) {
return new AppointmentSourceService.Source(key,customer,orderId,null,null,null,origin,"美国仓",destination,destination.substring(4),10,"1",true,List.of());
}
@@ -16,7 +31,7 @@ class AppointmentBatchPreviewServiceTest {
when(sources.resolveBatch(refs)).thenReturn(List.of(source("ORDER:1","A",1L,"100","FBA:ONT8"),source("BOOKING:B-1","B",null,"100","FBA:ONT8")));
when(mapper.flags(List.of(1L))).thenReturn(List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,false)));
when(sources.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");
var preview=new AppointmentBatchPreviewService(sources,mapper).preview(refs);
var preview=new AppointmentBatchPreviewService(sources,mapper,verification()).preview(refs);
assertEquals("America/Los_Angeles",preview.destinationTimezone());
assertEquals(2,preview.group().customerCount());assertEquals(20,preview.group().packages());
assertTrue(preview.group().readinessIncomplete());assertNull(preview.group().readyFrom());
@@ -28,9 +43,9 @@ class AppointmentBatchPreviewServiceTest {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
when(sources.resolveBatch(refs)).thenReturn(List.of(source("ORDER:1","A",1L,"100","FBA:ONT8")));
var service=new AppointmentBatchPreviewService(sources,mapper);
var service=new AppointmentBatchPreviewService(sources,mapper,verification());
for(var flags:List.of(List.<AppointmentBatchPreviewMapper.Flags>of(),List.of(new AppointmentBatchPreviewMapper.Flags(1L,true,false,false)),
List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,true,false)),List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,false,true)))) {
List.of(new AppointmentBatchPreviewMapper.Flags(1L,false,true,false)))) {
when(mapper.flags(List.of(1L))).thenReturn(flags);
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value());
}
@@ -39,14 +54,14 @@ class AppointmentBatchPreviewServiceTest {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:A","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","BOOKING:B","FBA:ONT8"));
when(sources.resolveBatch(refs)).thenReturn(List.of(source("BOOKING:A","A",null,"100","FBA:ONT8"),source("BOOKING:B","B",null,"200","FBA:ONT8")));
assertThrows(ResponseStatusException.class,()->new AppointmentBatchPreviewService(sources,mapper).preview(refs));
assertThrows(ResponseStatusException.class,()->new AppointmentBatchPreviewService(sources,mapper,verification()).preview(refs));
verifyNoInteractions(mapper);
}
@Test void absentTimezoneStaysUnknownAndLookupFailureIsNotMasked() {
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:A","FBA:ONT8"));
when(sources.resolveBatch(refs)).thenReturn(List.of(source("BOOKING:A","A",null,"100","FBA:ONT8")));
var service=new AppointmentBatchPreviewService(sources,mapper);
var service=new AppointmentBatchPreviewService(sources,mapper,verification());
assertNull(service.preview(refs).destinationTimezone());
when(sources.destinationTimezone("ONT8")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"lookup failed"));
assertEquals(503,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value());
@@ -17,7 +17,7 @@ class AppointmentCargoMapperTest {
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_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT,active BOOLEAN)");
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)");
@@ -33,7 +33,7 @@ class AppointmentCargoMapperTest {
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)");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20,TRUE)");
}
session.clearCache();
var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0);
@@ -24,6 +24,11 @@ class AppointmentCoverageMapperTest {
s.execute("INSERT INTO ll_warehouse_transfer_order VALUES(30,3,'C','FBA','ACY2','SHIP-C',NULL)");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'AP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP,'EXT-1','America/Los_Angeles'),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP,'EXT-2','America/Los_Angeles')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A','ORDER:1','ORDER:1',20,'ONT8',TRUE),(2,1000,2,'B','ORDER:2','ORDER:1',8,'ONT8',TRUE),(3,2000,1,'A','ORDER:1','ORDER:1',20,'ONT8',FALSE),(4,1000,NULL,'D','BOOKING:B-1','BOOKING:1',NULL,'ONT8',TRUE)");
s.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD cargo_verification_version INT");
String baseline;try(var input=getClass().getResourceAsStream("/db/lastleg.sql")){baseline=new String(java.util.Objects.requireNonNull(input).readAllBytes(),java.nio.charset.StandardCharsets.UTF_8);}
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_cargo_verification (");
s.execute(baseline.substring(start,baseline.indexOf(';',start)+1));
s.execute("INSERT INTO ll_appointment_cargo_verification(id,customer_id,source_key,destination_key,version,package_count,shipment_references_json,operator_id,command_key,request_hash,created_at) VALUES(1,'A','ORDER:1','FBA:ONT8',1,19,'[]','operator','one',REPEAT('a',64),CURRENT_TIMESTAMP),(2,'A','ORDER:1','FBA:ONT8',2,20,'[\"FBA-A\"]','operator','two',REPEAT('b',64),CURRENT_TIMESTAMP),(3,'B','ORDER:1','FBA:ONT8',1,8,'[]','operator','one',REPEAT('c',64),CURRENT_TIMESTAMP)");
}
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)) {
@@ -35,6 +40,8 @@ class AppointmentCoverageMapperTest {
var external=mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("D","BOOKING:B-1","FBA:ONT8"))).get(0);assertNull(external.orderId());assertNull(external.packages());
var transfer=mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("C",3L))).get(0);assertEquals("FBA",transfer.destinationType());assertEquals("ACY2",transfer.fbaWarehouseCode());assertFalse(transfer.shipmentReferencesMissing());
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing());
var evidence=mapper.verifications(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")));
assertEquals(1,evidence.size());assertEquals(2,evidence.get(0).getVersion());assertEquals(20,evidence.get(0).getPackageCount());
}
}
}
}
@@ -13,7 +13,7 @@ class AppointmentCoverageServiceTest {
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","AP-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles");
return new AppointmentCoverageMapper.Fact(1L,"A","AP-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles",null);
}
@Test void completeQuantitySharesOneAppointmentWithItsActualConfirmationAndTimezone() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
@@ -42,7 +42,7 @@ class AppointmentCoverageServiceTest {
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()); verify(mapper,never()).sources(anyList());
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(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","BOOKING:B-1","BOOKING:2/ORDER:2","EXT-1","America/Los_Angeles")));
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","BOOKING:B-1","BOOKING:2/ORDER:2","EXT-1","America/Los_Angeles",null)));
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"))));
}
@@ -60,6 +60,22 @@ class AppointmentCoverageServiceTest {
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);
verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verify(mapper,times(1)).verifications(anyList()); verifyNoMoreInteractions(mapper);
}
@Test void independentVerificationSuppliesQuantityAndReferencesWithoutRewritingOriginalOrderFacts() {
var mapper=mock(AppointmentCoverageMapper.class);
when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",null,2,BEFORE,true)));
var evidence=new AppointmentCargoVerification();evidence.setCustomerId("A");evidence.setSourceKey("ORDER:1");evidence.setDestinationKey("FBA:ONT8");
evidence.setSourceVersion("ORDER:2");evidence.setPackageCount(20);evidence.setVersion(1);evidence.setShipmentReferencesJson("[\"FBA-A\"]");
when(mapper.verifications(anyList())).thenReturn(List.of(evidence));
when(mapper.appointments(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Fact(1L,"A","AP-1","SUCCESS",AFTER.plusDays(1),null,AFTER,10L,20,"ONT8","ORDER:1","ORDER:2","EXT-1","America/Los_Angeles",1)));
var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class));
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
var result=service.query(refs).get(0);
assertEquals("CONFIRMED",result.status());assertFalse(result.changeReviewRequired());assertFalse(result.missingFields().contains("FBA_SHIPMENT_REFERENCES"));
evidence.setVersion(2);evidence.setShipmentReferencesJson("[\"FBA-CHANGED\"]");
var changed=service.query(refs).get(0);
assertTrue(changed.changeReviewRequired());assertTrue(changed.missingFields().contains("SOURCE_CHANGED_REVIEW"));
assertEquals(result.appointments(),changed.appointments(),"核实资料变更不能重写原确认时间");
}
}
@@ -17,6 +17,53 @@ 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 orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
var sources=context.getBean(AppointmentSourceService.class);
var coverage=context.getBean(AppointmentCoverageService.class);
login(LIST);assertThrows(AccessDeniedException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8"));
login(CARGO);assertThrows(AccessDeniedException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8"));
login(LIST,CARGO);
assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(1L,"CLIENT"),1L,"A","FBA:ONT8"));
assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),0L,"A","FBA:ONT8"));
verifyNoInteractions(sources);
var ref=new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8");
var source=new AppointmentSourceService.Source("BOOKING:B-1","A",1L,"IN-A","B-1",null,"100","美国仓","FBA:ONT8","ONT8",10,"BOOKING:2/ORDER:1",true,List.of());
when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L)))).thenReturn(List.of(ref));
when(sources.resolve("BOOKING:B-1","A","FBA:ONT8")).thenReturn(source);
var snapshot=new AppointmentCoverageService.Coverage("A","BOOKING:B-1","FBA:ONT8","PENDING",List.of(),List.of(),source.sourceVersion(),false);
when(coverage.query(List.of(ref))).thenReturn(List.of(snapshot));
assertEquals(source,controller.resolveOrder(user(0L,"PLATFORM"),1L,"A","FBA:ONT8").data().source());
verify(sources,never()).resolve("ORDER:1","A","FBA:ONT8");
when(sources.canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)))).thenReturn(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1",null)));
when(sources.resolve("ORDER:1","OTHER","FBA:ONT8")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND));
assertEquals(404,assertThrows(ResponseStatusException.class,()->controller.resolveOrder(user(0L,"PLATFORM"),1L,"OTHER","FBA:ONT8")).getStatusCode().value());
verify(coverage,times(1)).query(anyList());
} finally {SecurityContextHolder.clearContext();}
}
@Test void batchContextRequiresBothListPermissionsAndRejectsCustomerIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController.class);
login(LIST);assertThrows(AccessDeniedException.class,()->controller.detail(user(0L,"PLATFORM"),"FBA_APPOINTMENT:1"));
login(LIST,CARGO);assertThrows(ResponseStatusException.class,()->controller.detail(user(1L,"CLIENT"),"FBA_APPOINTMENT:1"));
assertDoesNotThrow(()->controller.detail(user(0L,"PLATFORM"),"FBA_APPOINTMENT:1"));
} finally {SecurityContextHolder.clearContext();}
}
@Test void cargoVerificationRequiresIndependentWritePermissionAndPlatformIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentCargoVerificationController.class);
var request=new AppointmentCargoVerificationService.Save("FBA:ONT8","ORDER:1",0,10,List.of("FBA-A"),null,"verify");
login(LIST);assertThrows(AccessDeniedException.class,()->controller.get(user(0L,"PLATFORM"),"ORDER:1","A",null));
login(LIST,CARGO);controller.get(user(0L,"PLATFORM"),"ORDER:1","A",null);
assertThrows(AccessDeniedException.class,()->controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request));
login(LIST,CARGO,WRITE);assertThrows(AccessDeniedException.class,()->controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request));
login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT);
assertThrows(ResponseStatusException.class,()->controller.save(user(1L,"CLIENT"),"ORDER:1","A",request));
controller.save(user(0L,"PLATFORM"),"ORDER:1","A",request);
verify(context.getBean(AppointmentCargoVerificationService.class)).save("ORDER:1","A",request,"7","operator");
} finally {SecurityContextHolder.clearContext();}
}
@Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentSourceCreateController.class);
@@ -24,9 +71,12 @@ class AppointmentPlanningAuthorizationTest {
"100","ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"key");
login(LIST,CARGO,WRITE);
assertThrows(AccessDeniedException.class,()->controller.create(user(0L,"PLATFORM"),request));
assertThrows(AccessDeniedException.class,()->controller.repartition(user(0L,"PLATFORM"),null));
login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT);
assertThrows(ResponseStatusException.class,()->controller.create(user(1L,"CLIENT"),request));
assertThrows(ResponseStatusException.class,()->controller.repartition(user(1L,"CLIENT"),null));
assertDoesNotThrow(()->controller.create(user(0L,"PLATFORM"),request));
assertDoesNotThrow(()->controller.repartition(user(0L,"PLATFORM"),null));
login(LIST);assertDoesNotThrow(()->controller.unscheduled(user(0L,"PLATFORM"),1,20));
} finally {SecurityContextHolder.clearContext();}
}
@@ -91,6 +141,9 @@ class AppointmentPlanningAuthorizationTest {
private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); }
@Configuration @EnableMethodSecurity
static class Config {
@Bean com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController batchController() {return new com.baiyue.lastleg.inboundplan.controller.FbaAppointmentBatchController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class));}
@Bean AppointmentCargoVerificationService verificationService() {return mock(AppointmentCargoVerificationService.class);}
@Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);}
@Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); }
@Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); }
@Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); }
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
class AppointmentWorkCommandServiceTest {
@Test void sameCommandReplaysItsOriginalSnapshotAfterLaterChangesAndRejectsChangedContent() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state);
var first=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"已联系\"}");
var saved=f.commands.execute(state.getWorkItemKey(),first,ACTOR);
f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"second","{\"ownerId\":\"1\",\"followupDate\":\"2026-10-16\",\"note\":\"等待回复\"}"),ACTOR);
assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR));
assertEquals(2,f.history.size()); assertNull(saved.item().followupDate());
var changed=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"不同内容\"}");
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),changed,ACTOR)).getStatusCode().value());
var other=new Actor("2","运营乙",ACTOR.permissions());
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,other)).getStatusCode().value());
when(f.gateway.facts(anyList())).thenThrow(new IllegalStateException("source temporarily offline"));
assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR));
var revoked=new Actor("1","运营甲",Set.of(LIST,FOLLOWUP,MANAGE));
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,revoked)).getStatusCode().value());
}
@Test void directRouteGeneratesFourTypesOnceAndDoesNotCreateWarehouseReceipt() throws Exception {
var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(route);
var requirement=new Requirement("requirement-1",null,null,"CUSTOMER","洛杉矶港","客户仓","America/Los_Angeles",List.of(),List.of(),List.of());
when(f.gateway.container("1")).thenReturn(new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,"America/Los_Angeles",List.of(requirement)));
var request=f.command(Command.CONFIRM_ROUTE,0,"route","{\"flow\":\"DIRECT\",\"reason\":\"按客户直送要求\"}");
var result=f.commands.execute(route.getWorkItemKey(),request,ACTOR);
assertEquals(5,f.rows.size()); assertEquals(5,result.affectedWorkItemKeys().size());
assertTrue(f.store.get(route.getWorkItemKey()).isRetired());
assertFalse(f.store.all().stream().anyMatch(s->s.getType()==Type.WAREHOUSE_RECEIPT));
assertEquals(result,f.commands.execute(route.getWorkItemKey(),request,ACTOR)); assertEquals(5,f.rows.size());
assertThrows(ResponseStatusException.class,()->f.commands.execute(route.getWorkItemKey(),f.command(Command.CONFIRM_ROUTE,1,"switch","{\"flow\":\"TRANSFER\"}"),ACTOR));
}
@Test void externalHandoffRequiresExplicitAcceptanceAndOrdinaryReplyCannotClearIt() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(state);
f.commands.execute(state.getWorkItemKey(),f.command(Command.HANDOFF_RETURN,0,"handoff","{\"recipient\":{\"kind\":\"EXTERNAL\",\"id\":null,\"name\":\"新车队\"},\"reason\":\"原承运另有任务\"}"),ACTOR);
f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"reply","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"等待新车队回复\"}"),ACTOR);
assertTrue(pendingHandoff(f.store.get(state.getWorkItemKey())));
assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"empty","{\"handoffVersion\":1,\"note\":\"\"}"),ACTOR));
var accepted=f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"accept","{\"handoffVersion\":1,\"note\":\"已电话确认接手,联系人张先生\"}"),ACTOR);
assertEquals(HandoffStatus.ACCEPTED,f.store.get(state.getWorkItemKey()).getReturnHandoff().status());
assertEquals(ExecutionStatus.PENDING,accepted.item().executionStatus()); assertNull(accepted.item().actual());
assertEquals("1",f.history.get(f.history.size()-1).getOperatorId());
}
@Test void firstRequirementDoesNotReviewDownstreamButRealRescheduleDoes() throws Exception {
var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); f.add(receipt);
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); delivery.setTarget(slot("2026-10-17")); f.add(delivery);
var payload="{\"target\":{\"date\":\"2026-10-15\",\"startTime\":null,\"endDate\":null,\"endTime\":null,\"timezone\":\"America/Los_Angeles\"},\"bookingRequired\":true,\"receivingRequirements\":\"先接仓\",\"reason\":\"首次目标\"}";
f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,0,"first",payload),ACTOR);
assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,1,"second",payload.replace("2026-10-15","2026-10-18").replace("首次目标","改期")),ACTOR);
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertEquals(1,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"old","{\"reviewVersion\":0,\"note\":\"旧影响\"}"),ACTOR));
var resolved=f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"review","{\"reviewVersion\":1,\"note\":\"已核对安排\"}"),ACTOR);
assertFalse(resolved.item().reviewRequired()); assertFalse(f.store.get(receipt.getWorkItemKey()).isReviewRequired());
}
@Test void unsupportedPayloadAndReadOnlyActorCannotWrite() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state);
var readonly=new Actor("1","只读",Set.of(LIST,ORDER_READ));
var request=f.command(Command.SAVE_FOLLOWUP,0,"one","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\"}");
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),request,readonly)).getStatusCode().value());
var invalid=f.command(Command.SAVE_FOLLOWUP,0,"two","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\",\"executionStatus\":\"COMPLETED\"}");
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),invalid,ACTOR)).getStatusCode().value());
assertEquals(0,f.store.get(state.getWorkItemKey()).getVersion()); assertTrue(f.history.isEmpty());
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.slot;
class AppointmentWorkCommitmentServiceTest {
@Test void firstTargetSameDayConfirmationAndFirstAssignmentAreNotImpacts() {
var empty=new Commitment(null,null,null,"cargo",false);
var planned=new Commitment(slot("2026-10-16"),null,null,"cargo",false);
var confirmed=new Commitment(planned.target(),slot("2026-10-16"),"vehicle-1","cargo",false);
assertFalse(impacts(empty,planned)); assertFalse(impacts(planned,confirmed));
assertFalse(impacts(planned,new Commitment(null,confirmed.confirmed(),null,"cargo",false)));
assertTrue(impacts(planned,new Commitment(planned.target(),slot("2026-10-17"),null,"cargo",false)));
assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),"vehicle-2","cargo",false)));
assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),confirmed.assignment(),"changed",false)));
assertTrue(impacts(confirmed,new Commitment(null,null,null,"cargo",true)));
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkContractTest {
@Test void actionTargetsSerializeWithDiscriminatorAndSlotsNeverInventSeconds() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
var action=new Action(ActionCode.VERIFY_CARGO,"核实资料","work-1",new CargoTarget(new SourceReference("12","ORDER:1","FBA:2")));
var tree=json.readTree(json.writeValueAsString(action));
assertEquals("CARGO_VERIFICATION",tree.path("target").path("kind").asText());
assertEquals("12",tree.path("target").path("source").path("customerId").asText());
assertEquals(action,json.treeToValue(tree,Action.class));
var slot=new Slot(LocalDate.of(2026,10,15),LocalTime.of(10,30),null,null,"America/Los_Angeles");
assertEquals("10:30",json.readTree(json.writeValueAsString(slot)).path("startTime").asText());
var precise=new Slot(slot.date(),LocalTime.of(10,30,45,123000000),null,null,null);
assertEquals("10:30:45.123",json.readTree(json.writeValueAsString(precise)).path("startTime").asText());
assertEquals(precise,json.readValue(json.writeValueAsString(precise),Slot.class));
assertDoesNotThrow(()->AppointmentWorkPolicy.validateSourceSlot(precise));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->AppointmentWorkPolicy.validateSlot(precise));
}
@Test void openApiDefinesFrozenPathsAndDiscriminatedCommands() throws Exception {
var options=new org.yaml.snakeyaml.LoaderOptions();options.setAllowDuplicateKeys(false);
Map<String,Object> root; try(var input=getClass().getResourceAsStream("/openapi/appointment-work-v1.yaml")) { assertNotNull(input); root=new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(options)).load(input); }
var paths=(Map<?,?>)root.get("paths"); var prefix="/api/platform/v1/last-leg/appointment-work";
for(var path:List.of("/calendar","/summary","/items","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path));
var schemas=(Map<?,?>)((Map<?,?>)root.get("components")).get("schemas");
var command=(Map<?,?>)schemas.get("CommandRequest"); assertEquals(6,((List<?>)command.get("oneOf")).size());
var item=(Map<?,?>)((Map<?,?>)schemas.get("WorkItem")).get("properties"); assertTrue(item.containsKey("containers")); assertFalse(item.containsKey("containerBookingId"));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
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.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDirectTransportProviderTest {
@Test void pickupStartsDirectTransportButOnlyOriginalDestinationUnloadingCompletesIt() {
var client=mock(AppointmentWorkFirstlegClient.class);var provider=new AppointmentWorkDirectTransportProvider(client,"internal-test");
var request=new FactRequest("W",Type.TRANSPORT,Flow.DIRECT,"1","INBOUND:1",null,null,List.of(),List.of(),List.of());
var time=LocalDateTime.parse("2026-10-15T11:00:00");
var pickup=fact("W/carrier",ExecutionStatus.COMPLETED,new Actual(null,time,null));
when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.PENDING,null))));
var moving=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.IN_PROGRESS,moving.executionStatus());assertNull(moving.actual());assertNull(moving.tripKey());
when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.COMPLETED,new Actual(null,time.plusHours(2),null)))));
var done=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.COMPLETED,done.executionStatus());assertEquals(time.plusHours(2),done.actual().localOccurredAt());
assertFalse(provider.supports(Type.TRANSPORT,Flow.TRANSFER));
}
private Fact fact(String key,ExecutionStatus execution,Actual actual) {
return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null);
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkFactAdapterTest {
@Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of()));
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),null));
var target=new HashMap<String,Fact>(); AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null)));
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null))));
}
@Test void completedFactRequiresActualEvidence() {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of(fact(state,null,null,ExecutionStatus.COMPLETED,null))));
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.appointmentwork;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.fasterxml.jackson.databind.ObjectMapper;
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.sql.Connection;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkPersistenceTest {
@Test void ddlAndMapperEnforceStableIdentityVersionAndGlobalCommandKey() throws Exception {
var ds=database();
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentWorkMapper.class); var store=new AppointmentWorkStore(mapper,new ObjectMapper().findAndRegisterModules());
assertNotNull(store.lock()); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized);
var guard=store.lock(); guard.setInitialized(true); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized);
mapper.invalidateRequirements(); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized);
guard=store.lock(); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized);
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); store.insert(state);
assertThrows(RuntimeException.class,()->store.insert(state));
var stale=store.get(state.getWorkItemKey()); state.setOwner(new Owner("1","运营甲")); store.save(state);
assertEquals(1,store.get(state.getWorkItemKey()).getVersion());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->store.save(stale));
store.history(state,ACTOR,"SAVE_FOLLOWUP","等待回复","same-key","hash",Map.of("version",1));
assertEquals("运营甲",store.replay(state.getWorkItemKey(),"same-key").getOperatorName());
assertThrows(RuntimeException.class,()->store.history(state,new Actor("2","运营乙",ACTOR.permissions()),"SAVE_FOLLOWUP","其他人重复键","same-key","other",Map.of("version",2)));
assertEquals(1,store.history(state.getWorkItemKey()).size());
}
}
@Test void transactionRollbackCannotLeaveHalfGeneratedRoute() throws Exception {
var ds=database(); var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class);
var factory=new MybatisSqlSessionFactoryBuilder().build(config);
try(var session=factory.openSession(false)) {
var store=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),new ObjectMapper().findAndRegisterModules());
var state=new WorkTestFixture().state("1",Type.PICKUP,Flow.TRANSFER,null); store.insert(state);
store.history(state,ACTOR,"GENERATED","路线生成",null,null,null); session.rollback();
}
try(var session=factory.openSession(true)) { assertTrue(session.getMapper(AppointmentWorkMapper.class).all(10).isEmpty()); }
}
private JdbcDataSource database() throws Exception {
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:work-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
String ddl; try(var stream=getClass().getResourceAsStream("/db/appointment-work.sql")) { assertNotNull(stream); ddl=new String(stream.readAllBytes(),StandardCharsets.UTF_8); }
try(Connection connection=ds.getConnection(); var statement=connection.createStatement()) { for(var sql:ddl.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql); }
return ds;
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
class AppointmentWorkPolicyTest {
@Test void originalDeliveryScheduleDoesNotUndoTheRecordedNoBookingRequirement() {
var fixture=new WorkTestFixture();var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"DISPATCH:1");state.setDestinationKind("CUSTOMER");state.setBookingRequired(false);
var item=project(state,fact(state,null,slot("2026-10-16"),ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertEquals(PlanStatus.NOT_REQUIRED,item.planStatus());assertEquals(LocalDate.of(2026,10,16),item.calendarDate());assertEquals(ExecutionStatus.PENDING,item.executionStatus());
}
@Test void localActualWithoutTimezoneRemainsCompletedOnItsRecordedDate() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var actual=new Actual(null,LocalDateTime.parse("2026-10-12T23:45:00"),null);
var item=project(state,fact(state,null,null,ExecutionStatus.COMPLETED,actual),ACTOR,CLOCK);
assertEquals(LocalDate.of(2026,10,12),item.calendarDate()); assertEquals(ExecutionStatus.COMPLETED,item.executionStatus());
assertNull(item.timezone()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE"));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateActual(new Actual(null,null,null)));
}
@Test void sourcePlanWithoutTimezoneIsVisibleButNewRequirementRejectsIt() {
var source=new Slot(LocalDate.of(2026,10,16),LocalTime.of(10,30),null,null,null);
assertDoesNotThrow(()->validateSourceSlot(source)); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateSlot(source));
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.PICKUP,Flow.TRANSFER,null);
var item=project(state,fact(state,null,source,ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertEquals(source.date(),item.calendarDate()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE"));
}
@Test void followupDateNeverBecomesAnAppointmentAndUnknownTimezoneUsesExplicitBusinessClock() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.ROUTE,Flow.UNKNOWN,null); state.setTimezone(null); state.setFollowupDate(LocalDate.of(2026,10,14));
var item=project(state,null,ACTOR,CLOCK);
assertNull(item.calendarDate()); assertEquals(DateBasis.NONE,item.dateBasis()); assertTrue(followToday(item,CLOCK));
assertTrue(item.missingFields().stream().anyMatch(s->s.contains("America/Los_Angeles")));
assertEquals(ActionCode.CONFIRM_ROUTE,item.nextAction().code());
}
@Test void actualDateWinsInItsOwnZoneAndBucketsAreExclusive() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); state.setReviewRequired(true);
var actual=new Actual(Instant.parse("2026-10-16T01:00:00Z"),null,"America/Los_Angeles");
var fact=fact(state,slot("2026-10-12"),slot("2026-10-13"),ExecutionStatus.COMPLETED,actual);
var item=project(state,fact,ACTOR,CLOCK);
assertEquals(LocalDate.of(2026,10,15),item.calendarDate()); assertEquals(DateBasis.ACTUAL,item.dateBasis());
assertEquals(Bucket.COMPLETED,bucket(item)); assertEquals(new BucketCounts(0,0,0,0,1),counts(List.of(item))); assertTrue(item.overdueReasons().isEmpty());
assertEquals(ActionCode.VIEW_HISTORY,item.nextAction().code());
}
@Test void nonexistentAndAmbiguousLocalTimesAreRejectedButDateOnlyStaysDateOnly() {
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,8),LocalTime.of(2,30),null,null,"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,11,1),LocalTime.of(1,30),null,null,"America/Los_Angeles")));
assertDoesNotThrow(()->validateSlot(slot("2026-11-01")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,10,15),null,null,null,"-08:00")));
}
@Test void overnightSlotRequiresExplicitEndDateAndValidEndLocalTime() {
var day=LocalDate.of(2026,10,15);
assertDoesNotThrow(()->validateSlot(new Slot(day,LocalTime.of(23,0),day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),null,LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),day,LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,null,day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,7),LocalTime.of(23,0),LocalDate.of(2026,3,8),LocalTime.of(2,30),"America/Los_Angeles")));
}
@Test void pendingAcceptanceSurvivesAnEmptyReminderAndDoesNotGrantExecution() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
state.setReturnHandoff(new ReturnHandoff(2,new Responsibility(ResponsibilityKind.EXTERNAL,null,"原车队"),new Responsibility(ResponsibilityKind.EXTERNAL,null,"新车队"),HandoffStatus.PENDING_ACCEPTANCE,"原车次结束"));
var item=project(state,fact(state,null,null,ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertTrue(followToday(item,CLOCK)); assertEquals(ActionCode.ACCEPT_RETURN,item.nextAction().code());
assertEquals(ExecutionStatus.PENDING,item.executionStatus()); assertNull(item.actual());
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkQueryServiceTest {
@Test void globalSummaryRetainsUnknownRoutesAndReturnAfterDeliveryAcrossCalendarFilters() {
var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); route.setFollowupDate(LocalDate.of(2026,10,14)); f.add(route);
var returns=f.state("2",Type.EMPTY_RETURN,Flow.DIRECT,null); returns.setTarget(slot("2026-09-01")); f.add(returns);
var delivered=f.state("2",Type.DELIVERY,Flow.DIRECT,"r1"); f.add(delivered);
f.facts.put(delivered.getWorkItemKey(),fact(delivered,null,null,ExecutionStatus.COMPLETED,new Actual(Instant.parse("2026-10-14T20:00:00Z"),null,"America/Los_Angeles")));
assertEquals(new Summary(2,1,1,1,0),f.queries.summary(ACTOR));
assertEquals(0,f.queries.calendar(LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),Flow.TRANSFER,Set.of(),ACTOR).total());
assertEquals(2,f.queries.summary(ACTOR).all());
var day=f.queries.items(View.ALL,1,20,LocalDate.of(2026,10,14),null,null,null,Bucket.COMPLETED,null,ACTOR);
assertEquals(1,day.total()); assertEquals(delivered.getWorkItemKey(),day.records().get(0).workItemKey());
assertEquals(2,f.queries.items(View.ALL,1,20,null,null,null,null,null,null,ACTOR).total());
}
@Test void canonicalTripIsOneWorkItemAndMatchesEveryAssociatedContainer() {
var f=new WorkTestFixture(); var trip=f.state("1",Type.TRANSPORT,Flow.TRANSFER,null); trip.setTripKey("trip-1");
trip.setContainers(List.of(new ContainerReference("1","CB-1","A"),new ContainerReference("2","CB-2","B"))); f.add(trip);
assertEquals(1,f.queries.summary(ACTOR).all());
assertEquals(1,f.queries.items(View.ALL,1,20,null,null,null,"2",null,"B",ACTOR).total());
assertEquals(2,f.queries.detail(trip.getWorkItemKey(),ACTOR).item().containers().size());
}
@Test void incompleteBootstrapScopeDriftAndAuthorityFailureCannotProduceZero() {
var f=new WorkTestFixture(); f.guard.setInitialized(false);
assertEquals(503,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value());
f.guard.setInitialized(true); when(f.gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"new-scope"));
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value());
when(f.gateway.discover(isNull(),anyInt())).thenThrow(new IllegalStateException("offline"));
assertThrows(IllegalStateException.class,()->f.queries.summary(ACTOR));
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRouteServiceTest {
@Test void onlyCurrentCommittedItemsDefineRouteAndNoGuardOrRemoteReadIsRequired() {
var fixture=new WorkTestFixture();var old=fixture.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);old.setRetired(true);fixture.add(old);
var current=fixture.state("1",Type.DELIVERY,Flow.DIRECT,"INBOUND:1");fixture.add(current);
assertEquals(Flow.DIRECT,new AppointmentWorkRouteService(fixture.store).route("1").flow());
org.mockito.Mockito.verify(fixture.mapper,org.mockito.Mockito.never()).lockGuard();
org.mockito.Mockito.verifyNoInteractions(fixture.gateway);
}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkSyncServiceTest {
@Test void removingPrivateCargoDetachesOldContainerAndRestoresDemandWithoutRetiringOtherCargo() {
var f=new WorkTestFixture();
var source1=new SourceReference("C1","ORDER:1","CUSTOMER:1");var source2=new SourceReference("C2","ORDER:2","CUSTOMER:2");
var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:1","INBOUND:2"),List.of(source1,source2));
var first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
var second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var root=new Requirement("INBOUND:1",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source1));
first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root));
var remainingBatch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:2"),List.of(source2));
second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(remainingBatch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var active=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();assertEquals(2,active.size());
var kept=active.stream().filter(s->"DISPATCH:1".equals(s.getBatchKey())).findFirst().orElseThrow();
assertEquals(List.of("2"),kept.getContainers().stream().map(ContainerReference::containerBookingId).toList());assertEquals("2",kept.getContainerBookingId());
assertFalse(kept.getPrerequisites().contains(AppointmentWorkPolicy.key("1",Type.WAREHOUSE_RECEIPT,null)));
assertFalse(f.store.container("1").stream().anyMatch(s->"DISPATCH:1".equals(s.getBatchKey())));
assertEquals(List.of(source2),kept.getSources());
var root2=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source2));
second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root2));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
assertEquals(2,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired() && s.getBatchKey()==null).count());
}
@Test void removedPrivateBatchCannotBeReplacedByAnotherCustomersUnrelatedDemand() {
var f=new WorkTestFixture();var oldSource=new SourceReference("C1","ORDER:1","CUSTOMER:1");
var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,null,null,List.of(),List.of(),List.of(oldSource));
var container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var unrelated=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,null,null,List.of(),List.of(),List.of(new SourceReference("C2","ORDER:2","CUSTOMER:2")));
container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(unrelated));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10)));
}
@Test void cancellationReopensOriginalDemandAndRestoringTheSameAppointmentDoesNotDuplicateWork() {
var f=new WorkTestFixture();
var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1"),List.of());
var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of());
for(var requirement:List.of(root,batch,root,batch)) {
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(requirement));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
f.sync.synchronize(new SyncRequest(null,10));
var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();
assertEquals(1,deliveries.size()); assertEquals(requirement.requirementKey(),deliveries.get(0).getRequirementKey());
}
}
@Test void localRequirementVersionChangingBetweenPagesCannotBePublishedComplete() {
var f=new WorkTestFixture();f.guard.setScanCursor("10");f.guard.setScanScopeVersion("scope");f.guard.setScanRequirementVersion(1);f.guard.setRequirementVersion(2);
when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"scope"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10)));
assertEquals(0,f.guard.getSynchronizedRequirementVersion());
}
@Test void serverDiscoveryCreatesUnknownRouteWithoutWarehouseBindingAndRetainsOldUnreturnedWork() {
var f=new WorkTestFixture(); var returns=f.state("old",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(returns);
var source=new ContainerSource("new","柜new",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of());
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
assertEquals(1,f.sync.synchronize(new SyncRequest(null,10)).created());
assertEquals(0,f.sync.synchronize(new SyncRequest(null,10)).created());
assertEquals(2,f.rows.size()); assertNotNull(f.store.get(returns.getWorkItemKey()));
assertTrue(f.store.all().stream().anyMatch(s->s.getFlow()==Flow.UNKNOWN && s.getType()==Type.ROUTE));
}
@Test void threeTransferRequirementsGenerateOnlyOnePickupReceiptAndReturn() {
var f=new WorkTestFixture();
var requirements=List.of("one","two","three").stream().map(id->new Requirement(id,null,null,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())).toList();
var source=new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",requirements);
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10));
assertEquals(9,f.rows.size());
for(var type:List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN)) assertEquals(1,f.store.all().stream().filter(s->s.getType()==type).count());
}
@Test void firstConfirmationUsesBaselineWhileSubsequentImpactMarksOnlyArrangedRelatedWork() {
var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); receipt.setTarget(slot("2026-10-15")); receipt.setObservedFingerprint("old"); receipt.setObservedImpactToken("same-impact"); receipt.setObservedArranged(true); f.add(receipt);
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setTarget(slot("2026-10-16")); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(delivery);
var unscheduled=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r2"); unscheduled.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(unscheduled);
var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
var facts=new HashMap<String,Fact>(); facts.put(receipt.getWorkItemKey(),first); facts.put(delivery.getWorkItemKey(),fact(delivery,null,null,ExecutionStatus.PENDING,null)); facts.put(unscheduled.getWorkItemKey(),fact(unscheduled,null,null,ExecutionStatus.PENDING,null));
f.sync.observe(f.store.all(),facts,ACTOR); assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
f.sync.observe(f.store.all(),facts,ACTOR);
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertFalse(f.store.get(unscheduled.getWorkItemKey()).isReviewRequired());
int version=f.store.get(delivery.getWorkItemKey()).getReviewVersion(); f.sync.observe(f.store.all(),facts,ACTOR); assertEquals(version,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
}
@Test void changingDiscoveryScopeDuringPaginationIsNotMarkedComplete() {
var f=new WorkTestFixture(); f.guard.setInitialized(false); f.guard.setScanCursor("10"); f.guard.setScanScopeVersion("before");
when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"after"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10)));
assertFalse(f.guard.isInitialized());
}
@Test void sharedTripAcrossTwoContainersIsPersistedOnceAndKeepsBothContainers() {
var f=new WorkTestFixture();
var sources=List.of("1","2").stream().map(id->new ContainerSource(id,"柜"+id,Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",
List.of(new Requirement("R"+id,"B"+id,"AP"+id,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())))).toList();
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(sources,null,true,"scope"));
when(f.gateway.facts(anyList())).thenAnswer(call->{
var values=new HashMap<String,Fact>();
for(var state:call.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) {
if(state.getType()==Type.TRANSPORT) values.put(state.getWorkItemKey(),new Fact(state.getWorkItemKey(),"trip-v1","trip-impact-v1",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,
slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null));
}
return values;
});
f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10));
var transports=f.store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.TRANSPORT).toList();
assertEquals(1,transports.size()); assertEquals(2,transports.get(0).getContainers().size()); assertEquals(List.of("B1","B2"),transports.get(0).getBatchKeys());
assertEquals(9,f.store.all().stream().filter(s->!s.isRetired()).count());
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
final class WorkTestFixture {
static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-15T04:00:00Z"),ZoneOffset.UTC);
static final Actor ACTOR=new Actor("1","运营甲",Set.of(LIST,ORDER_READ,FOLLOWUP,MANAGE));
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
final AppointmentWorkMapper mapper=mock(AppointmentWorkMapper.class);
final AppointmentWorkFactGateway gateway=mock(AppointmentWorkFactGateway.class);
final Map<String,AppointmentWorkMapper.Row> rows=new TreeMap<>();
final Map<String,Fact> facts=new HashMap<>();
final List<AppointmentWorkMapper.History> history=new ArrayList<>();
final AppointmentWorkMapper.Guard guard=new AppointmentWorkMapper.Guard();
final AppointmentWorkStore store=new AppointmentWorkStore(mapper,json);
final AppointmentWorkSyncService sync=new AppointmentWorkSyncService(store,gateway);
final AppointmentWorkQueryService queries=new AppointmentWorkQueryService(store,gateway,CLOCK);
final AppointmentWorkCommandService commands=new AppointmentWorkCommandService(store,gateway,sync,queries);
WorkTestFixture() {
guard.setId(1); guard.setInitialized(true); guard.setScopeVersion("scope");
when(mapper.guard()).thenReturn(guard); when(mapper.lockGuard()).thenReturn(guard);
when(mapper.get(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(mapper.all(anyInt())).thenAnswer(c->rows.values().stream().limit(c.getArgument(0,Integer.class)).toList());
when(mapper.container(anyString())).thenAnswer(c->rows.values().stream().filter(r->r.getContainerBookingId().equals(c.getArgument(0))).toList());
when(mapper.insert(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); if(rows.putIfAbsent(row.getWorkItemKey(),row)!=null) throw new org.springframework.dao.DuplicateKeyException("duplicate"); return 1; });
when(mapper.update(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); var old=rows.get(row.getWorkItemKey()); if(old==null || old.getVersion()!=row.getVersion()-1) return 0; rows.put(row.getWorkItemKey(),row); return 1; });
when(mapper.insertHistory(any(AppointmentWorkMapper.History.class))).thenAnswer(c->{history.add(c.getArgument(0)); return 1;});
when(mapper.selectHistory(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0))).toList());
when(mapper.command(anyString(),anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && Objects.equals(h.getCommandKey(),c.getArgument(1))).findFirst().orElse(null));
when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap<String,Fact>(); for(var state:c.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; });
when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope"));
when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲"));
when(gateway.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements());
}
AppointmentWorkState state(String container,Type type,Flow flow,String requirement) {
var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container);
state.setContainers(List.of(new ContainerReference(container,"CB-"+container,"柜"+container))); state.setType(type); state.setFlow(flow); state.setRequirementKey(requirement); state.setSourceVersion("v1"); state.setTimezone("America/Los_Angeles");
return state;
}
void add(AppointmentWorkState state) { store.insert(state); }
CommandRequest command(Command command,int version,String id,String payload) throws Exception { return new CommandRequest(command,version,id,json.readTree(payload)); }
static Slot slot(String date) { return new Slot(LocalDate.parse(date),null,null,null,"America/Los_Angeles"); }
static Fact fact(AppointmentWorkState state,Slot target,Slot confirmed,ExecutionStatus execution,Actual actual) {
return new Fact(state.getWorkItemKey(),"fact-v1","impact-v1",target!=null || confirmed!=null,confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,
execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.contract;
import org.junit.jupiter.api.Test;
import java.nio.file.*;
import java.util.List;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentWorkSchemaContractTest {
@Test void completeWorkAndPhysicalAllocationSchemaMatchesBothInitializationEntries() throws Exception {
String source=Files.readString(Path.of("src/main/resources/db/appointment-work.sql"));
for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
String target=Files.readString(Path.of(path));
for(String table:List.of("ll_dispatch_appointment_allocation","ll_appointment_work_commitment","ll_appointment_work_guard","ll_appointment_work","ll_appointment_work_history"))
assertEquals(definition(source,table),definition(target,table),path+" differs for "+table);
assertTrue(target.contains("INSERT INTO ll_appointment_work_guard(id,initialized) SELECT 1,FALSE"));
}
assertTrue(source.contains("scan_cursor VARCHAR(128)"));
assertTrue(source.contains("PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key)"));
}
@Test void overseasInitializationReportsSuccessOnlyAfterEveryTableAndSeed() throws Exception {
String sql=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
long count=Pattern.compile("(?m)^CREATE TABLE\\s+").matcher(sql).results().count();
String pass="SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, "+count+" AS initialized_tables;";
assertTrue(sql.stripTrailing().endsWith(pass));
assertEquals(1,Pattern.compile("SELECT 'PASS' AS initialization_status").matcher(sql).results().count());
String reset=Files.readString(Path.of("../deploy/sql/reset-test-fba-appointments.sql"));
assertTrue(reset.indexOf("CALL assert_test_appointment_reset_preconditions()")<reset.indexOf("DROP TABLE ll_inbound_fba_appointment_item"));
assertTrue(reset.contains("'ll_fba_appointment_command','ll_appointment_cargo_verification'"));
}
private String definition(String sql,String table) {
var match=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+Pattern.quote(table)+" \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
assertTrue(match.find(),"Missing "+table);
String result=match.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
assertFalse(match.find(),"Duplicate "+table);return result;
}
}
@@ -50,6 +50,15 @@ class DispatchCandidateMapperContractTest {
}
assertEquals(8, count);
}
statement.execute("INSERT INTO ll_inbound_fba_appointment VALUES (99,'2026-10-15 10:00:00',NULL,'SUCCESS','ACY2')");
statement.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES (99,1,'9',1,'ACY2')");
try(var rows=statement.executeQuery("SELECT "+DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT+" AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=1")) {
assertTrue(rows.next());assertEquals(null,rows.getTimestamp("appointment_at"),"一票多批时不得将最晚确认冒充整个托盘的预约时间");
}
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_status='PENDING' WHERE id=99");
try(var rows=statement.executeQuery("SELECT "+DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT+" AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=1")) {
assertTrue(rows.next());assertEquals(null,rows.getTimestamp("appointment_at"),"部分批次确认也不能代表整票");
}
for (Method method : List.of(
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class, String.class, String.class, String.class),
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
@@ -71,6 +71,8 @@ class DispatchServiceTest {
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class));
ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements);
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
@@ -28,6 +28,18 @@ class FleetTripCalendarMapperTest {
assertThat(rows).extracting(FleetTripEntity::getTripNo).containsExactly("TR-FROM","TR-LAST");
assertThat(rows).extracting(FleetTripEntity::getStatus).containsExactly("CANCELLED","COMPLETED");
assertThat(rows.get(0).getPlannedDepartureAt()).isEqualTo(from);
// The actual month is authoritative even when the original plan is outside this window.
jdbc.update(insert,"TR-ACTUAL-IN","IN_TRANSIT",from.minusDays(3),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from.plusHours(2),"TR-ACTUAL-IN");
jdbc.update(insert,"TR-ACTUAL-OUT","IN_TRANSIT",from.plusHours(3),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",to,"TR-ACTUAL-OUT");
jdbc.update(insert,"TR-ACTUAL-FROM","IN_TRANSIT",from.minusDays(2),to);
jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no=?",from,"TR-ACTUAL-FROM");
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
jdbc.update("UPDATE ll_fleet_trip SET planned_departure_at=? WHERE trip_no=?",to.plusDays(20),"TR-ACTUAL-IN");
assertThat(mapper.calendar(from,to)).extracting(FleetTripEntity::getTripNo)
.containsExactly("TR-ACTUAL-FROM","TR-FROM","TR-ACTUAL-IN","TR-LAST");
var many=new ArrayList<Object[]>();for(int i=0;i<2002;i++)many.add(new Object[]{"TR-MANY-"+i,"PLANNED",from.plusHours(1),to});
jdbc.batchUpdate(insert,many);
assertThat(mapper.calendar(from,to)).hasSize(2001);
@@ -146,7 +146,10 @@ class FleetTripPersistenceTest {
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
planning=mock(FleetDispatchPlanningGateway.class);
ReflectionTestUtils.setField(service,"planning",planning);
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class));
ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class);
ReflectionTestUtils.setField(execution,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null));
@@ -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);
}
}