feat(appointments): unify pre-inbound planning and confirmed FBA bookings
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentBatchPreviewMapperTest {
|
||||
@Test void flagsUseRealCustomerOwnershipAndIgnoreCancelledAppointments() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:batch-preview-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT, customer_id VARCHAR(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("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)");
|
||||
}
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentBatchPreviewMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var flags=session.getMapper(AppointmentBatchPreviewMapper.class).flags(List.of(1L,2L,3L)).stream().collect(java.util.stream.Collectors.toMap(AppointmentBatchPreviewMapper.Flags::orderId,r->r));
|
||||
assertFalse(flags.get(1L).mixedDestinations());assertFalse(flags.get(1L).activeAppointment());
|
||||
assertTrue(flags.get(2L).detained());assertTrue(flags.get(3L).mixedDestinations());assertTrue(flags.get(3L).activeAppointment());
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.appointmentplanning;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentBatchPreviewServiceTest {
|
||||
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());
|
||||
}
|
||||
@Test void firstLegAndUnmaterializedExternalCargoCanBeCheckedTogetherAcrossCustomers() {
|
||||
var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentBatchPreviewMapper.class);
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","BOOKING:B-1","FBA:ONT8"));
|
||||
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)));
|
||||
var preview=new AppointmentBatchPreviewService(sources,mapper).preview(refs);
|
||||
assertEquals(2,preview.group().customerCount());assertEquals(20,preview.group().packages());
|
||||
assertTrue(preview.group().readinessIncomplete());assertNull(preview.group().readyFrom());
|
||||
assertEquals(List.of("READY_WINDOW_UNVERIFIED"),preview.missingFields());
|
||||
assertNull(preview.sources().get(1).orderId());
|
||||
verify(sources).resolveBatch(refs);verify(mapper).flags(List.of(1L));verifyNoMoreInteractions(sources,mapper);
|
||||
}
|
||||
@Test void missingRowsDetentionMixedDestinationsAndPriorAppointmentsBlockGrouping() {
|
||||
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);
|
||||
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)))) {
|
||||
when(mapper.flags(List.of(1L))).thenReturn(flags);
|
||||
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.preview(refs)).getStatusCode().value());
|
||||
}
|
||||
}
|
||||
@Test void groupingDifferentOriginsIsRejectedEvenWhenEveryIndividualSourceIsValid() {
|
||||
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));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
+19
-20
@@ -11,31 +11,30 @@ import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentCoverageMapperTest {
|
||||
@Test void readsOnlyMatchingCustomerAndPlanLinesAndExcludesCancelledAppointments() throws Exception {
|
||||
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)");
|
||||
@Test void coverageUsesStableSourcesBeforePlansAndTransferDestinationComesFromTheRealTransfer() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),order_type VARCHAR(32),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))");
|
||||
s.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),required_package_count INT,fba_warehouse_code VARCHAR(128))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),line_id BIGINT,plan_id BIGINT)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FBA','ONT8',8,1,CURRENT_TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,lastleg_order_id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(128),fba_shipment_no VARCHAR(128),tracking_no VARCHAR(128))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP,confirmation_no VARCHAR(128),timezone VARCHAR(64))");
|
||||
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),source_key VARCHAR(160),source_version VARCHAR(160),package_count INT,fba_warehouse_code VARCHAR(128),active BOOLEAN)");
|
||||
s.execute("INSERT INTO ll_order VALUES(1,'A','FIRST_LEG','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FIRST_LEG','FBA','ONT8',8,1,CURRENT_TIMESTAMP),(3,'C','WAREHOUSE_TRANSFER',NULL,'ACY2',1,0,CURRENT_TIMESTAMP)");
|
||||
s.execute("INSERT INTO ll_order_detail VALUES(1,'A','SHIP-A',NULL),(2,'B',NULL,NULL)");
|
||||
s.execute("INSERT INTO ll_inbound_plan_line VALUES(10,100,1,'A',20,'ONT8'),(20,200,2,'B',8,'ONT8')");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'APP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP)");
|
||||
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A',10,100),(2,1000,2,'B',20,200),(3,2000,1,'A',10,100),(4,1000,1,'A',20,200)");
|
||||
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)");
|
||||
}
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCoverageMapper.class);
|
||||
try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentCoverageMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var mapper=session.getMapper(AppointmentCoverageMapper.class);
|
||||
var refs=List.of(new AppointmentCoverageMapper.OrderRef("A",1L));
|
||||
var source=mapper.sources(refs).get(0); assertFalse(source.shipmentReferencesMissing()); assertEquals(20,source.packages());
|
||||
var facts=mapper.appointments(refs); assertEquals(1,facts.size()); assertEquals("APP-1",facts.get(0).appointmentNo()); assertEquals(20,facts.get(0).packages());
|
||||
var source=mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("A",1L))).get(0);assertFalse(source.shipmentReferencesMissing());assertEquals(20,source.packages());
|
||||
var facts=mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")));assertEquals(1,facts.size());assertEquals("EXT-1",facts.get(0).confirmationNo());assertEquals(20,facts.get(0).packages());
|
||||
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",1L))).isEmpty());
|
||||
assertTrue(mapper.appointments(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).isEmpty());
|
||||
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());
|
||||
try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_order_detail VALUES(1,'A',' ',NULL)"); }
|
||||
session.clearCache(); assertTrue(mapper.sources(refs).get(0).shipmentReferencesMissing());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -13,15 +13,15 @@ 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","FBA-CONFIRM-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8");
|
||||
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");
|
||||
}
|
||||
@Test void completeQuantitySharesOneAppointmentButDoesNotInventItsTimezone() {
|
||||
@Test void completeQuantitySharesOneAppointmentWithItsActualConfirmationAndTimezone() {
|
||||
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
|
||||
when(mapper.sources(anyList())).thenReturn(List.of(order()));
|
||||
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(11,10,"SUCCESS")));
|
||||
var result=new AppointmentCoverageService(mapper,gateway).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
|
||||
assertEquals("CONFIRMED",result.status()); assertEquals(1,result.appointments().size());
|
||||
assertNull(result.appointments().get(0).timezone()); assertTrue(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
|
||||
assertEquals("America/Los_Angeles",result.appointments().get(0).timezone()); assertEquals("EXT-1",result.appointments().get(0).confirmationNo());assertFalse(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
|
||||
assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway);
|
||||
}
|
||||
@Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() {
|
||||
@@ -40,9 +40,9 @@ class AppointmentCoverageServiceTest {
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8"));
|
||||
var service=new AppointmentCoverageService(mapper,gateway);
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L)));
|
||||
assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verifyNoInteractions(mapper);
|
||||
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(fact(10,20,"SUCCESS")));
|
||||
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")));
|
||||
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"))));
|
||||
}
|
||||
|
||||
+29
@@ -16,6 +16,31 @@ 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 savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentSourceCreateController.class);
|
||||
var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")),
|
||||
"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));
|
||||
login(LIST,CARGO,com.baiyue.lastleg.security.OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT);
|
||||
assertThrows(ResponseStatusException.class,()->controller.create(user(1L,"CLIENT"),request));
|
||||
assertDoesNotThrow(()->controller.create(user(0L,"PLATFORM"),request));
|
||||
login(LIST);assertDoesNotThrow(()->controller.unscheduled(user(0L,"PLATFORM"),1,20));
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
@Test void batchPreviewIsReadOnlyButRequiresBothListPermissionsAndPlatformIdentity() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentBatchPreviewController.class);
|
||||
var request=new AppointmentBatchPreviewController.Request(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")));
|
||||
login(LIST);
|
||||
assertThrows(AccessDeniedException.class,()->controller.preview(user(0L,"PLATFORM"),request));
|
||||
login(LIST,CARGO);
|
||||
assertThrows(ResponseStatusException.class,()->controller.preview(user(1L,"CLIENT"),request));
|
||||
controller.preview(user(0L,"PLATFORM"),request);
|
||||
verify(context.getBean(AppointmentBatchPreviewService.class)).preview(request.sources());
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test void containerReadsAndWritesRequirePlatformIdentityAndSeparatePermissions() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(AppointmentContainerController.class);
|
||||
@@ -65,6 +90,10 @@ 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 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); }
|
||||
@Bean AppointmentBatchPreviewController batchPreviewController(AppointmentBatchPreviewService service) { return new AppointmentBatchPreviewController(service); }
|
||||
@Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); }
|
||||
@Bean AppointmentContainerController containerController(AppointmentContainerService containers,AppointmentFollowupService followups) { return new AppointmentContainerController(containers,followups); }
|
||||
@Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); }
|
||||
|
||||
+4
-2
@@ -41,8 +41,8 @@ class AppointmentPlanningPolicyTest {
|
||||
@Test void sourceIdentityMustNotBeCountedAgainAfterMaterialization() {
|
||||
var first = cargo("BOOKING:B-1", "A", "WH-1", "FBA:ONT8", 10, null, null);
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, first)));
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
|
||||
cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null))));
|
||||
var unknown=AppointmentPlanningPolicy.group(List.of(cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null)));
|
||||
assertNull(unknown.packages());assertEquals(1,unknown.unknownQuantitySources());
|
||||
}
|
||||
|
||||
@Test void nonUsAndBlockedSourcesCannotBeTreatedAsEligibleAmericanCargo() {
|
||||
@@ -74,6 +74,8 @@ class AppointmentPlanningPolicyTest {
|
||||
var result = AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "America/Los_Angeles", null, null);
|
||||
assertEquals(Instant.parse("2026-10-15T16:00:00Z"), result.start()); assertNull(result.end());
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "UTC-08:00", null, null));
|
||||
assertEquals(Instant.parse("2026-11-01T08:30:00Z"),AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-11-01T01:30:00"),null,"America/Phoenix",null,null).start());
|
||||
}
|
||||
|
||||
@Test void daylightSavingGapIsRejectedAndOverlapRequiresAnExplicitValidOffset() {
|
||||
|
||||
+17
-1
@@ -12,6 +12,22 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentSourceServiceTest {
|
||||
@Test void batchResolvesTwoCustomersWithOneCatalogAndOneOrderAndShippingQuery() {
|
||||
var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);
|
||||
var gateway=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
|
||||
var a=new LastlegOrder();a.setId(1L);a.setCustomerId("A");a.setOrderType("FIRST_LEG");a.setSourceContainerBookingId("C1");a.setOverseasWarehouseId("100");a.setDestinationType("FBA");a.setFbaWarehouseCode("ONT8");a.setPackageCount(10);
|
||||
var b=new LastlegOrder();b.setId(2L);b.setCustomerId("B");b.setOrderType("FIRST_LEG");b.setSourceContainerBookingId("C1");b.setOverseasWarehouseId("100");b.setDestinationType("FBA");b.setFbaWarehouseCode("ONT8");b.setPackageCount(20);
|
||||
when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(a,b));
|
||||
var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40);
|
||||
when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate));
|
||||
var service=new AppointmentSourceService(orders,shipping,gateway,warehouse,"token");
|
||||
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8"));
|
||||
var result=service.resolveBatch(refs);assertEquals(2,result.size());assertEquals(20,result.get(1).packages());
|
||||
verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(warehouse,times(1)).catalog("token");verify(gateway,times(1)).resolveOrders(anyList());verifyNoMoreInteractions(gateway);
|
||||
assertThrows(ResponseStatusException.class,()->service.resolveBatch(List.of(refs.get(0),refs.get(0))));
|
||||
assertThrows(ResponseStatusException.class,()->service.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8"))));
|
||||
}
|
||||
@Test void confirmedExternalBookingCanBeFollowedWithoutCreatingAnOrderAndKeepsItsKeyAfterMaterialization() {
|
||||
var orders=mock(LastlegOrderMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class);
|
||||
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(
|
||||
@@ -21,7 +37,7 @@ class AppointmentSourceServiceTest {
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,null,1L)));
|
||||
var source=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertTrue(source.canFollowup()); assertNull(source.orderId()); assertNull(source.packages()); verifyNoInteractions(orders);
|
||||
var order=new LastlegOrder(); order.setId(1L); order.setCustomerId("A"); order.setOverseasWarehouseId("100"); order.setPackageCount(12); order.setInboundCode("IN-A"); order.setDestinationType("FBA"); order.setFbaWarehouseCode("ONT8");
|
||||
when(orders.selectById(1L)).thenReturn(order);
|
||||
when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order));
|
||||
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,10,2L)));
|
||||
var received=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertEquals("BOOKING:B-1",received.sourceKey()); assertEquals(12,received.packages()); assertEquals("IN-A",received.inboundCode());
|
||||
assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:LAX9"));
|
||||
|
||||
+45
@@ -14,6 +14,51 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
@Test
|
||||
void confirmedAppointmentUsesOriginalCustomerOrderAndDestinationAndNeverInventsLoadingTime() throws Exception {
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-confirmed-appointment;MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT, customer_id VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT, plan_id BIGINT,destination_warehouse_code VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT,fba_warehouse_code VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_at TIMESTAMP,planned_at TIMESTAMP,appointment_status VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
statement.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR,active INT,fba_warehouse_code VARCHAR)");
|
||||
for (int i = 1; i <= 8; i++) {
|
||||
statement.execute("INSERT INTO ll_inbound_plan VALUES ("+i+",'9')");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES ("+i+","+i+",'ACY2')");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_line VALUES ("+i+","+i+",'9',"+i+",'OLD')");
|
||||
statement.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES ("+i+","+i+",'9')");
|
||||
statement.execute("INSERT INTO ll_inbound_fba_appointment VALUES ("+i+",'2026-10-12 10:00:00','2026-10-11 09:00:00','SUCCESS','ACY2')");
|
||||
statement.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES ("+i+","+i+",'9',1,'ACY2')");
|
||||
}
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_status='PENDING' WHERE id=2");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment_item SET active=0 WHERE appointment_id=3");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment_item SET customer_id='OTHER' WHERE appointment_id=4");
|
||||
statement.execute("UPDATE ll_inbound_plan_pallet SET destination_warehouse_code='ONT8' WHERE id=5");
|
||||
statement.execute("UPDATE ll_inbound_plan_line SET customer_id='OTHER' WHERE id=6");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment SET appointment_at=NULL WHERE id=7");
|
||||
statement.execute("UPDATE ll_inbound_fba_appointment_item SET fba_warehouse_code='ONT8' WHERE appointment_id=8");
|
||||
try (var rows = statement.executeQuery("SELECT p.id," + DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT
|
||||
+ " AS appointment_at FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id ORDER BY p.id")) {
|
||||
int count = 0;
|
||||
while (rows.next()) {
|
||||
count++;
|
||||
assertEquals(count == 1 ? java.sql.Timestamp.valueOf("2026-10-12 10:00:00") : null, rows.getTimestamp("appointment_at"));
|
||||
}
|
||||
assertEquals(8, count);
|
||||
}
|
||||
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))) {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains(DispatchCandidateMapper.CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt"));
|
||||
assertFalse(sql.contains("expected_loading_at"));
|
||||
assertFalse(sql.contains("fi.line_id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalTransferCreationModeDoesNotGrantReservationOwnership() throws Exception {
|
||||
for (Method method : List.of(
|
||||
|
||||
+49
@@ -47,5 +47,54 @@ class FleetPlanningReadPersistenceTest {
|
||||
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-5")).findFirst().orElseThrow().packageCount()).isNull();
|
||||
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-6")).findFirst().orElseThrow().packageCount()).isEqualTo(10);
|
||||
assertThat(sources).allMatch(s->s.lastlegOrderId()==null&&s.expectedPalletCount()==null);
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64))");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(501,'9'),(601,'9'),(701,'OTHER'),(801,'9')");
|
||||
jdbc.update("INSERT INTO ll_fleet_pool_order_link VALUES(5,'9',501,'LB-5','WAITING_INBOUND'),(6,'9',601,'LB-6','RECEIVED'),(6,'9',701,'INVALID','RECEIVED')");
|
||||
var adapter = new FleetAppointmentSourceAdapter(resolver);
|
||||
var bookingRefs = List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("9", "FB-6"),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("9", "FB-5"));
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(bookingRefs)).isInstanceOf(IllegalStateException.class);
|
||||
var transaction = new org.springframework.transaction.support.TransactionTemplate(new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds));
|
||||
transaction.executeWithoutResult(status -> {
|
||||
adapter.lockForAppointment(bookingRefs);
|
||||
adapter.lockForAppointment(List.of(bookingRefs.get(0), bookingRefs.get(0)));
|
||||
adapter.lockForAppointment(List.of());
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("OTHER", "FB-5"))))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef("9", "MISSING"))))
|
||||
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(java.util.Collections.nCopies(101, bookingRefs.get(0))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> adapter.lockForAppointment(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.SourceRef(" ", "FB-5"))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
});
|
||||
var lockingMapper = org.mockito.Mockito.mock(FleetPoolOrderMapper.class);
|
||||
org.mockito.Mockito.when(lockingMapper.lockAppointmentSource("FB-5")).thenReturn(new FleetPoolSourceResolver.SourceRef("9", "FB-5"));
|
||||
org.mockito.Mockito.when(lockingMapper.lockAppointmentSource("FB-6")).thenReturn(new FleetPoolSourceResolver.SourceRef("9", "FB-6"));
|
||||
var lockingAdapter = new FleetAppointmentSourceAdapter(new FleetPoolSourceResolver(lockingMapper, new ObjectMapper()));
|
||||
transaction.executeWithoutResult(status -> lockingAdapter.lockForAppointment(List.of(bookingRefs.get(0), bookingRefs.get(1), bookingRefs.get(0))));
|
||||
var lockOrder = org.mockito.Mockito.inOrder(lockingMapper);
|
||||
lockOrder.verify(lockingMapper).lockAppointmentSource("FB-5");
|
||||
lockOrder.verify(lockingMapper).lockAppointmentSource("FB-6");
|
||||
lockOrder.verifyNoMoreInteractions();
|
||||
var refs = List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 501L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 601L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 501L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("OTHER", 601L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 701L),
|
||||
new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 801L));
|
||||
var resolved = adapter.resolveOrders(refs);
|
||||
assertThat(resolved).extracting(s -> s.bookingNo()).containsExactlyInAnyOrder("FB-5", "FB-6");
|
||||
assertThat(resolved).filteredOn(s -> s.bookingNo().equals("FB-5")).singleElement().satisfies(s -> {
|
||||
assertThat(s.packageCount()).isNull(); assertThat(s.orderId()).isEqualTo(501L);
|
||||
assertThat(s.customerId()).isEqualTo("9"); assertThat(s.destinationKey()).isEqualTo("FBA:ACY2");
|
||||
});
|
||||
assertThat(adapter.resolveOrders(List.of())).isEmpty();
|
||||
assertThat(adapter.resolveOrders(null)).isEmpty();
|
||||
assertThatThrownBy(() -> adapter.resolveOrders(java.util.Collections.nCopies(101, refs.get(0))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> adapter.resolveOrders(List.of(new com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef("9", 0L))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_external_booking", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -28,10 +28,11 @@ class FleetPoolOrderBridgeTest {
|
||||
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
|
||||
LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
|
||||
DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class);
|
||||
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
|
||||
FleetPoolOrderBridge service;
|
||||
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
|
||||
@BeforeEach void setup(){
|
||||
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence);
|
||||
service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
|
||||
when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L));
|
||||
when(evidence.returns("9",70)).thenReturn(List.of());
|
||||
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
|
||||
@@ -48,6 +49,23 @@ class FleetPoolOrderBridgeTest {
|
||||
LocalDateTime.of(2030,1,1,12,0),"SELF_DELIVERY","客户自送",null,"货物",4,2,
|
||||
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
|
||||
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
|
||||
|
||||
@Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() {
|
||||
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
|
||||
incoming();
|
||||
var writes=inOrder(links,loose,appointmentSources);
|
||||
writes.verify(links).updateById(row);
|
||||
writes.verify(loose).updateById(looseOrder);
|
||||
writes.verify(appointmentSources).materialized("9","FB-7",77L);
|
||||
incoming();
|
||||
verify(appointmentSources,times(1)).materialized("9","FB-7",77L);
|
||||
}
|
||||
|
||||
@Test void appointmentBindingFailurePropagatesToTheMandatoryIncomingTransaction() {
|
||||
service.confirm(input("US_TRUCK"),"op");
|
||||
doThrow(new IllegalStateException("appointment binding conflict")).when(appointmentSources).materialized("9","FB-7",77L);
|
||||
assertThatThrownBy(this::incoming).isInstanceOf(IllegalStateException.class).hasMessageContaining("appointment binding conflict");
|
||||
}
|
||||
FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of(
|
||||
new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE),
|
||||
new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);}
|
||||
|
||||
+7
-1
@@ -27,9 +27,15 @@ class InboundPlanSchemaContractTest {
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge"));
|
||||
assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment"));
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (appointment_no)"));
|
||||
assertTrue(schema.contains("expected_loading_at"));
|
||||
String appointment = schema.substring(schema.indexOf("create table if not exists ll_inbound_fba_appointment ("), schema.indexOf("create table if not exists ll_inbound_fba_appointment_item ("));
|
||||
assertFalse(appointment.contains("customer_id"));
|
||||
assertFalse(appointment.contains("expected_loading_at"));
|
||||
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)"));
|
||||
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"));
|
||||
assertTrue(schema.contains("planned_arrival_date"));
|
||||
|
||||
+2
-3
@@ -55,12 +55,11 @@ class InboundPlanControllerTest {
|
||||
@Test
|
||||
void platformAppointmentUpdateUsesSharedAppointmentNumber() {
|
||||
UpdateFbaAppointmentRequest request = new UpdateFbaAppointmentRequest(
|
||||
LocalDateTime.of(2026, 8, 20, 10, 30), LocalDateTime.of(2026, 8, 21, 8, 0),
|
||||
"PENDING", "dock", null, 0);
|
||||
new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,LocalDateTime.of(2026,8,20,10,30),null,null,null,null,"PENDING","dock",null,null),0,"update-key");
|
||||
|
||||
controller.updateFbaAppointment(platform(), "APT-1", request);
|
||||
|
||||
verify(appointmentService).update("APT-1", request);
|
||||
verify(appointmentService).update("7", "APT-1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+4
-4
@@ -25,8 +25,8 @@ class InboundPlanDispatchStatusPersistenceTest {
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,plan_no VARCHAR(50),customer_id VARCHAR(20),overseas_warehouse_id BIGINT,assigned_flag BOOLEAN,merge_required BOOLEAN,urgent_flag BOOLEAN,status VARCHAR(30),created_at TIMESTAMP,version INT)");
|
||||
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,line_id BIGINT,appointment_id BIGINT,customer_id VARCHAR(20))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,created_at TIMESTAMP,appointment_no VARCHAR(50),appointment_status VARCHAR(30),appointment_at TIMESTAMP,expected_loading_at TIMESTAMP)");
|
||||
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_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,8 +83,8 @@ 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,CURRENT_TIMESTAMP)");
|
||||
db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(8,2,7,'A'),(9,5,7,'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_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");
|
||||
for (String customer : new String[]{"A", "B"}) {
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import javax.sql.DataSource;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FbaAppointmentPersistenceTest {
|
||||
interface OrderVersionProbe {
|
||||
@org.apache.ibatis.annotations.Select("SELECT version FROM ll_order WHERE id=#{id}")
|
||||
int version(long id);
|
||||
}
|
||||
@Test void lockingOrdersInvalidatesFactsReadBeforeTheLock() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var transaction=new org.springframework.transaction.support.TransactionTemplate(context.getBean(DataSourceTransactionManager.class));
|
||||
transaction.execute(status->{
|
||||
var probe=context.getBean(SqlSessionTemplate.class).getMapper(OrderVersionProbe.class);
|
||||
assertEquals(0,probe.version(1L));
|
||||
new JdbcTemplate(context.getBean(DataSource.class)).update("UPDATE ll_order SET version=1 WHERE id=1");
|
||||
assertEquals(0,probe.version(1L));
|
||||
context.getBean(InboundFbaAppointmentQueryMapper.class).lockOrders(List.of(1L));
|
||||
assertEquals(1,probe.version(1L));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
private static List<AppointmentSourceService.Source> cargo(Integer secondPackages) {
|
||||
return List.of(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,null,"100","美国仓","FBA:ONT8","ONT8",10,"ORDER:1",true,List.of()),
|
||||
new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓","FBA:ONT8","ONT8",secondPackages,"ORDER:1",true,List.of()));
|
||||
}
|
||||
private static SourceFbaAppointmentRequest request(String key) {
|
||||
return new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8")),
|
||||
"100","ONT8",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),key);
|
||||
}
|
||||
@Test void failureAfterHeaderAndFirstItemRollsBackTheCommandAndEntireAggregate() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);assertTrue(org.springframework.aop.support.AopUtils.isAopProxy(service));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
db.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD CONSTRAINT fail_second_item CHECK(package_count<>13)");
|
||||
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(cargo(13));
|
||||
assertThrows(RuntimeException.class,()->service.createFromSources("operator",request("retry-after-rollback")));
|
||||
for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_fba_appointment_command")) assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
|
||||
db.execute("ALTER TABLE ll_inbound_fba_appointment_item DROP CONSTRAINT fail_second_item");
|
||||
var saved=service.createFromSources("operator",request("retry-after-rollback"));
|
||||
assertEquals(saved,service.createFromSources("operator",request("retry-after-rollback")));
|
||||
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",Integer.class));
|
||||
assertEquals(1,service.unscheduled(1,20).total());
|
||||
}
|
||||
}
|
||||
@Test void concurrentDifferentCommandsCannotReserveTheSameCrossCustomerCargoTwice() throws Exception {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var first=executor.submit(()->attempt(service,start,"first"));var second=executor.submit(()->attempt(service,start,"second"));start.countDown();
|
||||
assertEquals(1,(first.get(10,TimeUnit.SECONDS)?1:0)+(second.get(10,TimeUnit.SECONDS)?1:0));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
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(1,db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command",Integer.class));
|
||||
} finally {executor.shutdownNow();}
|
||||
}
|
||||
}
|
||||
@Test void cancellationReleasesGeneratedClaimsWhileReplayKeepsItsOriginalSnapshot() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var first=service.createFromSources("operator",request("first"));
|
||||
service.delete("operator",first.appointmentNo(),0);
|
||||
var second=service.createFromSources("operator",request("second"));assertNotEquals(first.appointmentNo(),second.appointmentNo());
|
||||
assertEquals("CANCELLED",service.detail(first.appointmentNo()).appointmentStatus());
|
||||
assertEquals(first,service.createFromSources("operator",request("first")));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
|
||||
}
|
||||
}
|
||||
@Test void unknownQuantityOnReviewDoesNotSilentlyKeepThePreviousKnownQuantity() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@Test void actualIncomingBindsTheOriginalBookingInItsOwnTransactionWithoutChangingPlannedFacts() {
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var service=context.getBean(InboundFbaAppointmentService.class);var linker=context.getBean(AppointmentSourceLinker.class);
|
||||
var before=new AppointmentSourceService.Source("BOOKING:B-1","C",null,null,"B-1",null,"100","美国仓","FBA:ONT8","ONT8",null,"BOOKING:1",true,List.of());
|
||||
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(before));
|
||||
var request=new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming");
|
||||
var saved=service.createFromSources("operator",request);
|
||||
assertNull(saved.items().get(0).orderId());
|
||||
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->linker.materialized("C","B-1",100L));
|
||||
var transaction=new org.springframework.transaction.support.TransactionTemplate(context.getBean(DataSourceTransactionManager.class));
|
||||
var db=new JdbcTemplate(context.getBean(DataSource.class));
|
||||
assertThrows(IllegalStateException.class,()->transaction.execute(status->{db.update("INSERT INTO ll_order(id) VALUES(100)");linker.materialized("C","B-1",100L);throw new IllegalStateException("incoming failed after link");}));
|
||||
assertNull(service.detail(saved.appointmentNo()).items().get(0).orderId());
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE id=100",Integer.class));
|
||||
transaction.execute(status->{db.update("INSERT INTO ll_order(id) VALUES(100)");linker.materialized("C","B-1",100L);linker.materialized("C","B-1",100L);return null;});
|
||||
var linked=service.detail(saved.appointmentNo());assertEquals(100L,linked.items().get(0).orderId());assertEquals("WT-REAL",linked.items().get(0).inboundCode());
|
||||
assertNull(linked.items().get(0).packageCount());assertEquals("BOOKING:1",linked.items().get(0).sourceVersion());assertEquals("PENDING",linked.appointmentStatus());
|
||||
assertEquals(saved,service.createFromSources("operator",request));
|
||||
assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
|
||||
}
|
||||
}
|
||||
private static boolean attempt(InboundFbaAppointmentService service,CountDownLatch start,String key) throws Exception {
|
||||
start.await();try{service.createFromSources("operator",request(key));return true;}catch(org.springframework.web.server.ResponseStatusException error){assertEquals(409,error.getStatusCode().value());return false;}
|
||||
}
|
||||
@Configuration @EnableTransactionManagement
|
||||
static class Config {
|
||||
@Bean DataSource dataSource() throws Exception {
|
||||
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")) {
|
||||
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))");
|
||||
}return ds;
|
||||
}
|
||||
@Bean DataSourceTransactionManager transactionManager(DataSource dataSource){return new DataSourceTransactionManager(dataSource);}
|
||||
@Bean SqlSessionFactory sessionFactory(DataSource dataSource) {
|
||||
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);
|
||||
return new MybatisSqlSessionFactoryBuilder().build(config);
|
||||
}
|
||||
@Bean SqlSessionTemplate template(SqlSessionFactory factory){return new SqlSessionTemplate(factory);}
|
||||
@Bean InboundFbaAppointmentMapper appointmentMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentMapper.class);}
|
||||
@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 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 AppointmentSourceLinker(orders,items,bookings);}
|
||||
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
|
||||
}
|
||||
}
|
||||
+144
-255
@@ -1,286 +1,175 @@
|
||||
package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.*;
|
||||
import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot;
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InboundFbaAppointmentServiceTest {
|
||||
@Mock InboundFbaAppointmentMapper appointmentMapper;
|
||||
@Mock InboundFbaAppointmentItemMapper itemMapper;
|
||||
@Mock InboundFbaAppointmentQueryMapper queryMapper;
|
||||
InboundFbaAppointmentMapper headers;
|
||||
InboundFbaAppointmentItemMapper items;
|
||||
InboundFbaAppointmentQueryMapper query;
|
||||
FbaAppointmentCommandMapper commands;
|
||||
AppointmentSourceService sources;
|
||||
AppointmentBatchPreviewMapper flags;
|
||||
InboundFbaAppointmentService service;
|
||||
final Map<String,AppointmentSourceService.Source> cargo=new LinkedHashMap<>();
|
||||
final List<InboundFbaAppointmentItem> storedItems=new ArrayList<>();
|
||||
final List<InboundFbaAppointment> storedHeaders=new ArrayList<>();
|
||||
final Map<String,FbaAppointmentCommandMapper.Command> journal=new HashMap<>();
|
||||
final LocalDateTime at=LocalDateTime.parse("2026-10-15T09:00:00");
|
||||
@BeforeEach void setup() {
|
||||
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,"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)));
|
||||
when(commands.begin(anyString(),anyString(),anyString(),anyString(),any())).thenAnswer(c->{journal.put(c.getArgument(0),new FbaAppointmentCommandMapper.Command(c.getArgument(1),null));return 1;});
|
||||
when(commands.finish(anyString(),anyString(),anyString())).thenAnswer(c->{String key=c.getArgument(0);journal.put(key,new FbaAppointmentCommandMapper.Command(journal.get(key).requestHash(),c.getArgument(2)));return 1;});
|
||||
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(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));
|
||||
when(headers.update(any(InboundFbaAppointment.class),any())).thenReturn(1);
|
||||
when(items.insert(any(InboundFbaAppointmentItem.class))).thenAnswer(c->{storedItems.add(c.getArgument(0));return 1;});
|
||||
when(items.selectList(any())).thenAnswer(c->List.copyOf(storedItems));
|
||||
add("ORDER:1","A",1L,"100","ONT8",10);add("ORDER:2","B",2L,"100","ONT8",20);
|
||||
}
|
||||
void add(String key,String customer,Long order,String origin,String destination,Integer packages) {
|
||||
cargo.put(key,new AppointmentSourceService.Source(key,customer,order,order==null?null:"IN-"+order,key.startsWith("BOOKING:")?key.substring(8):null,null,origin,"美国仓","FBA:"+destination,destination,packages,"ORDER:1",true,List.of()));
|
||||
}
|
||||
List<AppointmentCoverageService.SourceRef> refs(String... keys) {return Arrays.stream(keys).map(key->new AppointmentCoverageService.SourceRef(cargo.get(key).customerId(),key,cargo.get(key).destinationKey())).toList();}
|
||||
FbaAppointmentDetails pending(String remark) {return new FbaAppointmentDetails(LocalDate.of(2026,10,15),null,null,null,null,null,"PENDING",remark,null,null);}
|
||||
FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(LocalDate.of(2026,10,14),null,null,"EXT-ONT8-1",at,at.plusHours(1),"SUCCESS","已核实",null,null);}
|
||||
SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(refs(sources),"100","ONT8",details,key);}
|
||||
FbaAppointmentResponse create(String key) {return service.createFromSources("operator",request(key,pending(null),"ORDER:1","ORDER:2"));}
|
||||
|
||||
private InboundFbaAppointmentService service;
|
||||
private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30);
|
||||
private final LocalDateTime expectedLoadingAt = LocalDateTime.of(2026, 8, 21, 8, 0);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new InboundFbaAppointmentService();
|
||||
ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper);
|
||||
ReflectionTestUtils.setField(service, "itemMapper", itemMapper);
|
||||
ReflectionTestUtils.setField(service, "queryMapper", queryMapper);
|
||||
lenient().when(itemMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(appointmentMapper.update(any(InboundFbaAppointment.class), any())).thenReturn(1);
|
||||
@Test void refusesOriginOrDestinationChangedAfterSelection() {
|
||||
var intent=request("origin-changed",pending(null),"ORDER:1");
|
||||
add("ORDER:1","A",1L,"200","ONT8",10);
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",intent)).getStatusCode().value());
|
||||
add("ORDER:1","A",1L,"100","LAX9",10);
|
||||
var changed=new SourceFbaAppointmentRequest(refs("ORDER:1"),"100","ONT8",pending(null),"destination-changed");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",changed)).getStatusCode().value());
|
||||
verify(headers,never()).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception {
|
||||
assertEquals(true, InboundFbaAppointmentService.class
|
||||
.getMethod("batchCreate", String.class, BatchFbaAppointmentRequest.class)
|
||||
.getAnnotation(Transactional.class) != null);
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8")));
|
||||
|
||||
var result = service.batchCreate("7", request(List.of(11L, 12L), "APT-1"));
|
||||
|
||||
assertEquals("APT-1", result.appointmentNo());
|
||||
assertEquals("ONT8", result.fbaWarehouseCode());
|
||||
assertEquals(expectedLoadingAt, result.expectedLoadingAt());
|
||||
assertEquals(2, result.selectedCount());
|
||||
var captured = org.mockito.ArgumentCaptor.forClass(InboundFbaAppointmentItem.class);
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(captured.capture());
|
||||
assertEquals(List.of("CUSTOMER-11", "CUSTOMER-12"), captured.getAllValues().stream()
|
||||
.map(InboundFbaAppointmentItem::getCustomerId).toList());
|
||||
verify(appointmentMapper).insert(any(InboundFbaAppointment.class));
|
||||
verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class));
|
||||
@Test void savesCrossCustomerArrangementBeforeAnyInboundPlanOrExternalNumberExists() {
|
||||
var result=create("create");assertTrue(result.appointmentNo().startsWith("AP-"));assertNull(result.confirmationNo());assertNull(result.appointmentAt());
|
||||
assertEquals(LocalDate.of(2026,10,15),result.plannedDate());assertEquals(2,result.selectedCount());assertEquals("PENDING",result.appointmentStatus());
|
||||
assertEquals(List.of("A","B"),result.items().stream().map(FbaAppointmentResponse.Item::customerId).toList());verify(query,never()).selectedLines(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMixedFbaWarehouses() {
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(
|
||||
line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@Test void unknownQuantityAndUnmaterializedBookingRemainUnknownInPendingArrangement() {
|
||||
add("BOOKING:B-1","B",null,"100","ONT8",null);
|
||||
var result=service.createFromSources("operator",request("unknown",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"ORDER:1","BOOKING:B-1"));
|
||||
assertNull(result.plannedDate());assertNull(result.items().get(1).orderId());assertNull(result.items().get(1).inboundCode());assertNull(result.items().get(1).packageCount());
|
||||
verify(query,never()).countMissingShipmentReferences(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingSelectedLine() {
|
||||
when(queryMapper.lockLines(List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L, 12L), "APT-1")));
|
||||
@Test void inboundEntryCanonicalizesTheOriginalBookingInsteadOfCreatingAnOrderAlias() {
|
||||
add("BOOKING:B-1","B",2L,"100","ONT8",20);
|
||||
when(query.selectedLines(List.of(11L))).thenReturn(List.of(new FbaAppointmentLineSnapshot(11L,100L,2L,"IN-2","ONT8","B")));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(refs("BOOKING:B-1"));
|
||||
var result=service.batchCreate("operator",new BatchFbaAppointmentRequest(List.of(11L),"100","ONT8",pending(null),"lines"));
|
||||
assertEquals("BOOKING:B-1",result.items().get(0).sourceKey());verify(sources).canonicalOrders(List.of(new AppointmentExternalSourceGateway.OrderRef("B",2L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateLineIdsAreRejectedBeforeDatabaseAccess() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L, 11L), "APT-1")));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
@Test void mixedOriginsAndDestinationsAreNotSilentlyGrouped() {
|
||||
add("ORDER:2","B",2L,"200","ONT8",20);
|
||||
assertThrows(ResponseStatusException.class,()->create("mixed-origin"));
|
||||
add("ORDER:2","B",2L,"100","LAX9",20);
|
||||
assertThrows(ResponseStatusException.class,()->create("mixed-destination"));assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameAppointmentReplayReturnsExistingResult() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
item.setInboundCode("IN-11");
|
||||
item.setFbaWarehouseCode("ONT8");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
var result = service.batchCreate("7", request(List.of(11L), "APT-1"));
|
||||
|
||||
assertEquals(99L, result.id());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@Test void missingAndDuplicateInboundSelectionsAreRejected() {
|
||||
assertThrows(ResponseStatusException.class,()->service.batchCreate("operator",new BatchFbaAppointmentRequest(List.of(11L,11L),"100","ONT8",pending(null),"duplicates")));
|
||||
verify(query,never()).selectedLines(anyList());
|
||||
when(query.selectedLines(List.of(11L))).thenReturn(List.of());
|
||||
assertThrows(ResponseStatusException.class,()->service.batchCreate("operator",new BatchFbaAppointmentRequest(List.of(11L),"100","ONT8",pending(null),"missing")));
|
||||
assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameAppointmentNumberWithDifferentSelectionConflicts() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(12L), "APT-1")));
|
||||
@Test void exactCreateReplayReturnsOriginalResultEvenAfterLaterEditing() {
|
||||
var request=request("original",pending(null),"ORDER:1","ORDER:2");var first=service.createFromSources("operator",request);
|
||||
var edited=service.update("operator",first.appointmentNo(),new UpdateFbaAppointmentRequest(pending("新备注"),0,"edit"));assertEquals(1,edited.version());
|
||||
assertEquals(first,service.createFromSources("operator",request));verify(headers,times(1)).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameAppointmentNumberWithDifferentExpectedLoadingTimeConflicts() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
BatchFbaAppointmentRequest changed = new BatchFbaAppointmentRequest(List.of(11L), "APT-1",
|
||||
appointmentAt, expectedLoadingAt.plusHours(1), "SUCCESS", "dock", null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", changed));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
@Test void sameKeyWithDifferentCargoDetailsOrOperatorIsRejected() {
|
||||
create("original");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("original",pending(null),"ORDER:1"))).getStatusCode().value());
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("original",pending("different"),"ORDER:1","ORDER:2")));
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("other",request("original",pending(null),"ORDER:1","ORDER:2")));
|
||||
verify(headers,times(1)).insert(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringSnowflakeLineIdsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||
String json = "{\"lineIds\":[\"9007199254740993\"],"
|
||||
+ "\"appointmentNo\":\"APT-1\",\"appointmentAt\":\"2026-08-20T10:30:00\","
|
||||
+ "\"expectedLoadingAt\":\"2026-08-21T08:00:00\",\"appointmentStatus\":\"PENDING\"}";
|
||||
|
||||
BatchFbaAppointmentRequest request = new ObjectMapper().findAndRegisterModules()
|
||||
.readValue(json, BatchFbaAppointmentRequest.class);
|
||||
|
||||
assertEquals(List.of(9007199254740993L), request.lineIds());
|
||||
assertEquals(expectedLoadingAt, request.expectedLoadingAt());
|
||||
@Test void confirmationNeedsExternalNumberAndLocationTimezone() {
|
||||
var missing=new FbaAppointmentDetails(null,null,null,null,at,null,"SUCCESS",null,null,null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-number",missing,"ORDER:1")));
|
||||
when(sources.destinationTimezone("ONT8")).thenReturn(null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-zone",confirmed(),"ORDER:1")));assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesAppointmentDetailsWithoutReplacingSelectedLines() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
LocalDateTime loadingAt = expectedLoadingAt.plusHours(2);
|
||||
|
||||
var result = service.update("APT-1", new UpdateFbaAppointmentRequest(
|
||||
appointmentAt.plusDays(1), loadingAt, "PENDING", "updated", null, 0));
|
||||
|
||||
assertEquals(loadingAt, result.expectedLoadingAt());
|
||||
assertEquals("PENDING", result.appointmentStatus());
|
||||
verify(appointmentMapper).update(org.mockito.ArgumentMatchers.eq(existing), any());
|
||||
verify(itemMapper, never()).insert(any(InboundFbaAppointmentItem.class));
|
||||
@Test void confirmationRequiresActualShipmentReferencesAndKnownCargoWithoutFakingMaterialization() {
|
||||
when(query.countMissingShipmentReferences(List.of(1L))).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("missing-materials",confirmed(),"ORDER:1")));
|
||||
add("BOOKING:B-1","B",null,"100","ONT8",10);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("unmaterialized",confirmed(),"BOOKING:B-1")));
|
||||
add("ORDER:2","B",2L,"100","ONT8",null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("unknown",confirmed(),"ORDER:2")));assertTrue(storedHeaders.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preventsAssigningTheSameCargoToTwoActiveAppointments() {
|
||||
when(queryMapper.lockLines(List.of(11L))).thenReturn(List.of(line(11L, "IN-11", "ONT8")));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), null)).thenReturn(1L);
|
||||
var exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.batchCreate("7", request(List.of(11L), "SECOND-APT")));
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class));
|
||||
@Test void confirmationPersistsExternalNumberAndUtcSeparatelyFromPlannedDate() {
|
||||
var response=service.createFromSources("operator",request("confirm",confirmed(),"ORDER:1"));
|
||||
assertEquals("EXT-ONT8-1",response.confirmationNo());assertNotEquals(response.appointmentNo(),response.confirmationNo());
|
||||
assertEquals(Instant.parse("2026-10-15T16:00:00Z"),response.appointmentStartUtc());assertEquals(LocalDate.of(2026,10,14),response.plannedDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledAppointmentCannotBeReactivatedAfterItsCargoWasBookedElsewhere() {
|
||||
InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setAppointmentStatus("CANCELLED");
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setLineId(11L);
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
when(queryMapper.countActiveAppointments(List.of(11L), 99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.update("APT-1",
|
||||
new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt, "SUCCESS", "", null, 0)));
|
||||
verify(queryMapper).lockLines(List.of(11L));
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
@Test void invalidWindowsAndNonexistentDaylightSavingTimeAreRejected() {
|
||||
var invalid=new FbaAppointmentDetails(null,null,null,"EXT",at,at.minusMinutes(1),"SUCCESS",null,null,null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("bad-window",invalid,"ORDER:1")));
|
||||
var gap=new FbaAppointmentDetails(null,null,null,"EXT",LocalDateTime.parse("2026-03-08T02:30:00"),null,"SUCCESS",null,null,null);
|
||||
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("dst-gap",gap,"ORDER:1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAppointmentWindowWhoseEndIsNotAfterItsStart() {
|
||||
var request = new BatchFbaAppointmentRequest(List.of(11L), "BAD-WINDOW", appointmentAt,
|
||||
expectedLoadingAt, "PENDING", "", appointmentAt.minusMinutes(1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.batchCreate("7", request));
|
||||
verify(queryMapper, never()).lockLines(any());
|
||||
@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(items.insert(any(InboundFbaAppointmentItem.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("claimed"));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->create("raced")).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleEditBeforeWritingTheSharedAppointment() {
|
||||
var existing = appointment(99L, "APT-1", "ONT8");
|
||||
existing.setVersion(3);
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(existing);
|
||||
var request = new UpdateFbaAppointmentRequest(appointmentAt, expectedLoadingAt,
|
||||
"PENDING", "", appointmentAt.plusHours(2), 2);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.update("APT-1", request));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verify(appointmentMapper, never()).update(any(InboundFbaAppointment.class), any());
|
||||
@Test void staleUpdateAndStaleCancellationDoNotWrite() {
|
||||
var result=create("original");
|
||||
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending(null),2,"stale-update")));
|
||||
assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),2));
|
||||
verify(headers,never()).update(any(InboundFbaAppointment.class),any());verify(headers,never()).updateById(any(InboundFbaAppointment.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cannotDeleteAnAppointmentWhoseGoodsAlreadyHaveAnActiveDispatch() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(queryMapper.countActiveDispatches(99L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(appointmentMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
@Test void activeDispatchBlocksChangingOrCancellingItsAppointment() {
|
||||
var result=create("original");when(query.countActiveDispatches(result.id())).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(pending("change"),0,"blocked-update")));
|
||||
assertThrows(ResponseStatusException.class,()->service.delete("operator",result.appointmentNo(),0));assertTrue(storedItems.stream().allMatch(InboundFbaAppointmentItem::getActive));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeleteKeepsAllCargoAssociations() {
|
||||
when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8"));
|
||||
when(appointmentMapper.delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(0);
|
||||
assertThrows(ResponseStatusException.class, () -> service.delete("APT-1", 0));
|
||||
verify(itemMapper, never()).delete(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class));
|
||||
@Test void cancellationPreservesConfirmedFactsAndHistoryButCannotRestoreOverAnotherActiveClaim() {
|
||||
var result=service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"));
|
||||
service.delete("operator",result.appointmentNo(),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);
|
||||
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),1,"restore")));
|
||||
}
|
||||
|
||||
private BatchFbaAppointmentRequest request(List<Long> lineIds, String number) {
|
||||
return new BatchFbaAppointmentRequest(lineIds, number, appointmentAt, expectedLoadingAt,
|
||||
"SUCCESS", "dock", null);
|
||||
@Test void snowflakeLineSelectorsDeserializeWithoutPrecisionLoss() throws Exception {
|
||||
var request=new ObjectMapper().findAndRegisterModules().readValue("{\"lineIds\":[\"9007199254740993\"],\"details\":{\"appointmentStatus\":\"PENDING\"},\"idempotencyKey\":\"key\"}",BatchFbaAppointmentRequest.class);
|
||||
assertEquals(List.of(9007199254740993L),request.lineIds());assertNull(request.details().appointmentAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarRejectsInvalidOrUnboundedDateRangesBeforeQuerying() {
|
||||
LocalDate from = LocalDate.of(2026, 9, 1);
|
||||
for (LocalDate to : List.of(from, from.minusDays(1), from.plusDays(43))) {
|
||||
assertThrows(ResponseStatusException.class, () -> service.calendar(from, to));
|
||||
}
|
||||
verify(appointmentMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyCalendarDoesNotQueryAppointmentItems() {
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of());
|
||||
assertEquals(List.of(), service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1)));
|
||||
verify(itemMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calendarLoadsAllItemsInOneBatchAndKeepsAppointmentAssociation() {
|
||||
var first = appointment(99L, "APT-1", "ONT8");
|
||||
var second = appointment(100L, "APT-2", "LAX9");
|
||||
InboundFbaAppointmentItem item = new InboundFbaAppointmentItem();
|
||||
item.setAppointmentId(100L);
|
||||
item.setLineId(11L);
|
||||
item.setInboundCode("IN-11");
|
||||
when(appointmentMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||
when(itemMapper.selectList(any())).thenReturn(List.of(item));
|
||||
var result = service.calendar(LocalDate.of(2026, 9, 1), LocalDate.of(2026, 10, 1));
|
||||
assertEquals(2, result.size());
|
||||
assertEquals(0, result.get(0).items().size());
|
||||
assertEquals("IN-11", result.get(1).items().get(0).inboundCode());
|
||||
verify(itemMapper).selectList(any());
|
||||
}
|
||||
|
||||
private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) {
|
||||
return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba, "CUSTOMER-" + id);
|
||||
}
|
||||
|
||||
private InboundFbaAppointment appointment(Long id, String number, String fba) {
|
||||
InboundFbaAppointment value = new InboundFbaAppointment();
|
||||
value.setId(id);
|
||||
value.setAppointmentNo(number);
|
||||
value.setAppointmentAt(appointmentAt);
|
||||
value.setExpectedLoadingAt(expectedLoadingAt);
|
||||
value.setAppointmentStatus("SUCCESS");
|
||||
value.setAppointmentRemark("dock");
|
||||
value.setFbaWarehouseCode(fba);
|
||||
value.setSelectedCount(1);
|
||||
value.setVersion(0);
|
||||
return value;
|
||||
@Test void calendarRejectsInvalidRangesAndAvoidsPerRowDetailQueries() {
|
||||
var from=LocalDate.of(2026,9,1);for(var to:List.of(from,from.minusDays(1),from.plusDays(43)))assertThrows(ResponseStatusException.class,()->service.calendar(from,to));
|
||||
when(headers.selectList(any())).thenReturn(List.of());assertTrue(service.calendar(from,from.plusDays(30)).isEmpty());verify(items,never()).selectList(any());
|
||||
var one=create("first");when(headers.selectList(any())).thenReturn(List.copyOf(storedHeaders));clearInvocations(items);
|
||||
var calendar=service.calendar(from,from.plusDays(30));assertEquals(one.items(),calendar.get(0).items());verify(items,times(1)).selectList(any());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user