perf(lastleg): batch receiving candidates and trace slow work invalidation

This commit is contained in:
2026-10-17 07:49:56 +08:00
parent af97cd17d0
commit 5fd74e9c8c
9 changed files with 377 additions and 17 deletions
@@ -0,0 +1,130 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingCandidateMapper;
import com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import org.junit.jupiter.api.Test;
import java.util.*;
import java.util.stream.LongStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ReceivingCandidateRepositoryTest {
/** 多柜多客户仍仅按固定大小批量查询,没有逐柜或逐计划查询。 */
@Test
void hundredsOfContainersUseBoundedBatchQueriesWithoutDroppingCustomers() {
var orders=mock(LastlegOrderMapper.class);var plans=mock(InboundPlanMapper.class);
var lines=mock(InboundPlanLineMapper.class);
var mapper=mock(ReceivingCandidateMapper.class);var sessions=mock(ReceivingContainerSessionMapper.class);
var orderRows=LongStream.rangeClosed(1,240).mapToObj(id->{
var row=new LastlegOrder();row.setId(id);row.setCustomerId(id%2==0?"A":"B");
row.setSourceContainerBookingId("B"+((id+1)/2));row.setContainerNo("C"+((id+1)/2));return row;
}).toList();
var lineRows=LongStream.rangeClosed(1,240).mapToObj(id->{
var row=new InboundPlanLine();row.setId(id);row.setOrderId(id);row.setPlanId(id);return row;
}).toList();
var planRows=LongStream.rangeClosed(1,240).mapToObj(id->{var row=new InboundPlan();row.setId(id);return row;}).toList();
when(mapper.availableOrders()).thenReturn(orderRows.stream().map(o->
new ReceivingCandidateMapper.AvailableOrder(o.getId(),o.getSourceContainerBookingId())).toList());
when(orders.selectList(any())).thenReturn(orderRows);
when(lines.selectList(any())).thenReturn(lineRows);
when(plans.selectList(any())).thenReturn(planRows);
when(mapper.unconfirmedPlans(any())).thenReturn(List.of(9L));
when(mapper.received(any())).thenReturn(List.of(new ReceivingCandidateMapper.Received("A",2L,2L,7)));
var facts=new ReceivingCandidateRepository(orders,plans,lines,mapper,sessions).load();
assertThat(facts.orders()).hasSize(240).extracting(LastlegOrder::getCustomerId).contains("A","B");
assertThat(facts.unconfirmedPlans()).containsExactly(9L);
assertThat(facts.received()).containsEntry(new ReceivingCandidateRepository.LineKey("A",2L,2L),7);
verify(mapper).availableOrders();verify(orders).selectList(any());verify(lines).selectList(any());verify(plans).selectList(any());
verify(mapper).unconfirmedPlans(any());verify(mapper).received(any());
verifyNoMoreInteractions(orders,plans,lines,mapper);
}
/** 没有可收货节点时,不再全表读取订单、计划或明细。 */
@Test
void emptyShippingScopeSkipsAllDownstreamReads() {
var orders=mock(LastlegOrderMapper.class);var plans=mock(InboundPlanMapper.class);
var lines=mock(InboundPlanLineMapper.class);
var mapper=mock(ReceivingCandidateMapper.class);var sessions=mock(ReceivingContainerSessionMapper.class);
when(mapper.availableOrders()).thenReturn(List.of());
var repository=new ReceivingCandidateRepository(orders,plans,lines,mapper,sessions);
var active=new ReceivingContainerSession();active.setActiveOperatorKey("OPERATOR:7");
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(active);
when(sessions.findActiveContainers()).thenReturn(List.of(active));
assertThat(repository.currentSession("OPERATOR:7")).isSameAs(active);
assertThat(repository.activeSessions()).containsExactly(active);
verify(sessions).findActiveByOperator("OPERATOR:7");verify(sessions).findActiveContainers();verifyNoMoreInteractions(sessions);
assertThat(repository.load().orders()).isEmpty();
verifyNoInteractions(orders,plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper);
}
/** 大范围完整分块,超过一批后不丢柜、不产生超长 IN 参数。 */
@Test
void moreThanFiveHundredBookingsAreChunkedWithoutTruncation() {
var orders=mock(LastlegOrderMapper.class);var plans=mock(InboundPlanMapper.class);
var lines=mock(InboundPlanLineMapper.class);
var mapper=mock(ReceivingCandidateMapper.class);var sessions=mock(ReceivingContainerSessionMapper.class);
when(mapper.availableOrders()).thenReturn(LongStream.rangeClosed(1,501)
.mapToObj(id->new ReceivingCandidateMapper.AvailableOrder(id,"B"+id)).toList());
when(orders.selectList(any())).thenReturn(List.of());
var facts=new ReceivingCandidateRepository(orders,plans,lines,mapper,sessions).load();
assertThat(facts.availableOrderIds()).hasSize(501);
verify(orders,times(2)).selectList(any());verifyNoInteractions(plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper);
}
/** 真实 Mapper 汇总不会把其他客户或其他计划的同号行混入实收量。 */
@Test
void receivedSqlKeepsCustomerPlanAndLineIdentity() throws Exception {
var dataSource=new org.h2.jdbcx.JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:receiving_candidate_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var jdbc=new org.springframework.jdbc.core.JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(customer_id VARCHAR(128),plan_id BIGINT,line_id BIGINT,picked_package_count INT)");
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES ('A',1,10,3),('A',1,10,4),('B',1,10,90),('A',2,10,80),('A',1,99,70),('A',1,10,NULL),('A',3,10,NULL)");
var configuration=new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment(
"test",new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(),dataSource));
configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(ReceivingCandidateMapper.class);
try(var session=new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession()) {
assertThat(session.getMapper(ReceivingCandidateMapper.class).received(List.of(10L)))
.containsExactlyInAnyOrder(new ReceivingCandidateMapper.Received("A",1L,10L,7),
new ReceivingCandidateMapper.Received("B",1L,10L,90),new ReceivingCandidateMapper.Received("A",2L,10L,80),new ReceivingCandidateMapper.Received("A",3L,10L,0));
}
}
/** 真实 SQL 按数据库字符串比较匹配门禁,混合大小写柜号不由 Java 二次排除。 */
@Test
void shippingSqlPreservesDatabaseComparisonAndBookingOccurrence() {
var dataSource=new org.h2.jdbcx.JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:receiving_gate_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var jdbc=new org.springframework.jdbc.core.JdbcTemplate(dataSource);
jdbc.execute("CREATE TABLE ll_order(id BIGINT,source_container_booking_id VARCHAR_IGNORECASE(128),container_no VARCHAR_IGNORECASE(128))");
jdbc.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR_IGNORECASE(128) PRIMARY KEY,container_no VARCHAR_IGNORECASE(128),status VARCHAR_IGNORECASE(64))");
jdbc.update("INSERT INTO ll_order VALUES (1,'booking-a','cont-a'),(2,'BOOKING-A','CONT-A'),(3,'BOOKING-B','CONT-A'),(4,'BOOKING-A','WRONG')");
jdbc.update("INSERT INTO ll_container_shipping_gate VALUES ('BOOKING-A','CONT-A','RECEIVING_AVAILABLE'),('BOOKING-B','CONT-A','AWAITING_PICKUP_READY')");
var configuration=new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment(
"test",new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(),dataSource));
configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(ReceivingCandidateMapper.class);
try(var session=new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession()) {
assertThat(session.getMapper(ReceivingCandidateMapper.class).availableOrders()).containsExactlyInAnyOrder(
new ReceivingCandidateMapper.AvailableOrder(1L,"booking-a"),new ReceivingCandidateMapper.AvailableOrder(2L,"BOOKING-A"));
}
}
/** MySQL JSON_TABLE 的原资源谓词保持原样,动态批量参数不能丢客户精确匹配。 */
@Test
void unconfirmedSqlPreservesOriginalCustomerPredicateAndBindsEveryPlan() {
var configuration=new org.apache.ibatis.session.Configuration();configuration.addMapper(ReceivingCandidateMapper.class);
var bound=configuration.getMappedStatement(ReceivingCandidateMapper.class.getName()+".unconfirmedPlans")
.getBoundSql(Map.of("ids",List.of(1L,2L,3L)));
assertThat(bound.getSql()).contains(com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN)
.contains("o.customer_id=p.customer_id", "o.id=p.source_order_id");
assertThat(bound.getParameterMappings()).hasSize(3);
}
}
@@ -58,6 +58,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ReceivingContainerServiceBehaviorTest {
@Mock com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository candidateRepository;
@Mock InboundPlanMapper plans;
@Mock InboundPlanLineMapper lines;
@Mock InboundPlanPalletMapper pallets;
@@ -82,6 +83,32 @@ class ReceivingContainerServiceBehaviorTest {
@BeforeEach
void setUp() {
service = new ReceivingContainerService();
ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository);
org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation ->
sessions.findActiveByOperator(invocation.getArgument(0)));
org.mockito.Mockito.lenient().when(candidateRepository.activeSessions()).thenAnswer(ignored -> sessions.findActiveContainers());
// 既有行为用例继续提供同一组业务事实,批量 SQL 的调用边界由独立仓储测试验证。
org.mockito.Mockito.lenient().when(candidateRepository.load()).thenAnswer(ignored -> {
var orderRows = orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LastlegOrder>());
var planRows = plans.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundPlan>());
var lineRows = lines.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundPlanLine>());
var available = orderRows.stream().filter(order -> shippingGates.receivingAvailable(
order.getSourceContainerBookingId(), order.getContainerNo()) == 1)
.map(LastlegOrder::getId).collect(java.util.stream.Collectors.toSet());
var unconfirmed = planRows.stream().filter(plan -> plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0)
.map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
var received = new java.util.HashMap<com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey,Integer>();
for (var line : lineRows) {
var order = orderRows.stream().filter(row -> row.getId().equals(line.getOrderId())).findFirst().orElse(null);
if (order == null) continue;
int count = allocations.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundPlanPalletAllocation>())
.stream().mapToInt(row -> row.getPickedPackageCount() == null ? 0 : row.getPickedPackageCount()).sum();
received.put(new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey(
order.getCustomerId(), line.getPlanId(), line.getId()), count);
}
return new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.Facts(
orderRows, planRows, lineRows, available, unconfirmed, received);
});
ReflectionTestUtils.setField(service,"workRouteGuard",org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard.class));
ReflectionTestUtils.setField(service, "plans", plans);
ReflectionTestUtils.setField(service, "lines", lines);
@@ -109,6 +136,39 @@ class ReceivingContainerServiceBehaviorTest {
org.mockito.Mockito.lenient().when(domesticEvidence.listByContainer(any())).thenReturn(List.of());
}
/** 候选投影只消费批量事实,客户隔离的实收量不会混入其他客户或重复读库。 */
@Test
void candidatesUseBulkFactsWithoutPerContainerPlanOrLineQueries() {
var first = plan("CONT-A");
var second = plan("CONT-A"); second.setId(3L); second.setCustomerId("200");
var firstLine = line("FBA"); firstLine.setRequiredPackageCount(8);
var secondLine = line("FBA"); secondLine.setId(11L); secondLine.setOrderId(2L);
secondLine.setPlanId(3L); secondLine.setRequiredPackageCount(12);
var firstOrder = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1");
var secondOrder = containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2");
var facts = new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.Facts(
List.of(firstOrder, secondOrder), List.of(first, second), List.of(firstLine, secondLine),
java.util.Set.of(1L, 2L),
java.util.Set.of(), java.util.Map.of(
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("100", first.getId(), firstLine.getId()), 3,
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("200", second.getId(), secondLine.getId()), 5,
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("OTHER", first.getId(), firstLine.getId()), 99));
org.mockito.Mockito.doReturn(facts).when(candidateRepository).load();
var result = service.candidates("7", 100L);
assertThat(result).hasSize(1);
assertThat(result.get(0).totalOrderCount()).isEqualTo(2);
assertThat(result.get(0).totalPackages()).isEqualTo(20);
assertThat(result.get(0).receivedPackages()).isEqualTo(8);
assertThat(result.get(0).operable()).isTrue();
verify(candidateRepository).load();
verifyNoInteractions(orders, plans, lines, allocations, shippingGates, warehouse);
// 不可提前按仓过滤同柜另一客户,否则会把冲突柜错误显示为本仓可收货。
secondOrder.setOverseasWarehouseId("200");
assertThat(service.candidates("7", 100L)).isEmpty();
}
@Test
void containerIsHiddenAndPrepareIsRejectedBeforePickupReady() {
when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A")));
@@ -264,7 +264,8 @@ class FbaAppointmentPersistenceTest {
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);}
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(mapper);}
/** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(new com.baiyue.lastleg.appointmentwork.AppointmentWorkStore(mapper,json));}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService workCommitments(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService(mapper,json);}
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments,FbaReceivingStore receiving){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes,receiving);}
}