feat(appointments): unify pre-inbound planning and confirmed FBA bookings

This commit is contained in:
2026-10-10 10:38:06 +08:00
parent 24f5ccf7ff
commit c4c1bddeec
54 changed files with 1623 additions and 744 deletions
@@ -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());
}
}
}
@@ -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);
}
}
@@ -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());
}
}
}
}
@@ -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"))));
}
@@ -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); }
@@ -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() {
@@ -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"));