From 5fd74e9c8c779fa5fb9ce1fa740d0223d46a6746 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 07:49:56 +0800 Subject: [PATCH] perf(lastleg): batch receiving candidates and trace slow work invalidation --- .../AppointmentWorkChanges.java | 10 +- .../appointmentwork/AppointmentWorkStore.java | 18 +++ .../pda/mapper/ReceivingCandidateMapper.java | 35 +++++ .../ReceivingCandidateRepository.java | 76 ++++++++++ .../service/ReceivingContainerService.java | 52 +++++-- .../ReceivingCandidateRepositoryTest.java | 130 ++++++++++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 60 ++++++++ .../FbaAppointmentPersistenceTest.java | 3 +- .../lastleg-slice-dependencies.json | 10 ++ 9 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java index 8e57b4b66..f53f56d21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java @@ -4,13 +4,15 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -/** Original business commands invalidate the manifest atomically with their business write. */ +/** 原业务写入与工作清单失效保持在同一事务,不延后或绕过版本门禁。 */ @Component public class AppointmentWorkChanges { - private final AppointmentWorkMapper mapper; - public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; } + private final AppointmentWorkStore store; + /** 复用工作事项持久层,统一记录守卫更新耗时。 */ + public AppointmentWorkChanges(AppointmentWorkStore store) { this.store=store; } + /** 只标记需求变化,保留调用方原事务和异常语义。 */ @Transactional(propagation=Propagation.MANDATORY) public void requirementsChanged() { - if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化"); + store.invalidateRequirements(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java index 64b174221..b1a295229 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -1,6 +1,9 @@ package com.baiyue.lastleg.appointmentwork; import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.stereotype.Component; import java.time.*; import java.util.*; @@ -9,10 +12,25 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @Component public class AppointmentWorkStore { + private static final Logger LOG = LoggerFactory.getLogger(AppointmentWorkStore.class); public static final int MAX_ITEMS=10000; private final AppointmentWorkMapper mapper; private final ObjectMapper json; public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; } + /** 只测量守卫更新语句,不改变锁、事务或版本,用于区分慢写是否卡在该语句。 */ + public void invalidateRequirements() { + long started = System.nanoTime(); + try { + if (mapper.invalidateRequirements() != 1) throw unavailable("工作事项同步守卫未初始化"); + } finally { + long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + if (elapsedMs >= 250) { + // 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。 + LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + } + } + } public AppointmentWorkMapper.Guard lock() { var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java new file mode 100644 index 000000000..803fe4861 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** 候选列表的批量资源门禁与实收汇总,不改变原收货命令。 */ +@Mapper +public interface ReceivingCandidateMapper { + /** 柜次和柜号由数据库按原门禁的比较语义匹配,避免 Java 大小写比较缩小候选。 */ + @Select("SELECT o.id AS order_id,o.source_container_booking_id AS booking_id FROM ll_order o " + + "JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id " + + "AND g.container_no=o.container_no WHERE g.status='RECEIVING_AVAILABLE'") + List availableOrders(); + + record AvailableOrder(Long orderId, String bookingId) { } + + /** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */ + @Select("") + List unconfirmedPlans(@Param("ids") List ids); + + /** 实收始终按客户、计划及计划行三重身份汇总。 */ + @Select("") + List received(@Param("ids") List ids); + + record Received(String customerId, Long planId, Long lineId, int received) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java new file mode 100644 index 000000000..002b3e56d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.springframework.stereotype.Repository; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 一次请求内批量读取候选所需事实,避免按柜、计划和货件逐条查询。 */ +@Repository +public class ReceivingCandidateRepository { + private static final int CHUNK_SIZE = 500; + private final LastlegOrderMapper orders; + private final InboundPlanMapper plans; + private final InboundPlanLineMapper lines; + private final ReceivingCandidateMapper candidates; + private final ReceivingContainerSessionMapper sessions; + + /** 仅依赖当前收货业务的本库 Mapper。 */ + public ReceivingCandidateRepository(LastlegOrderMapper orders, InboundPlanMapper plans, + InboundPlanLineMapper lines, ReceivingCandidateMapper candidates, ReceivingContainerSessionMapper sessions) { + this.orders=orders; this.plans=plans; this.lines=lines; this.candidates=candidates; this.sessions=sessions; + } + + /** 先读取当前账号会话,供服务在加载候选前核对仓库归属。 */ + public ReceivingContainerSession currentSession(String activeOperatorKey) { + return sessions.findActiveByOperator(activeOperatorKey); + } + + /** 读取柜号占用事实,由服务排除其他操作账号正在处理的柜。 */ + public List activeSessions() { + return sessions.findActiveContainers(); + } + + public record LineKey(String customerId, Long planId, Long lineId) { } + public record Facts(List orders, List plans, List lines, + Set availableOrderIds, Set unconfirmedPlans, Map received) { } + + /** 先缩小到可收货柜,保留其全部客户订单,不能按当前仓提前裁掉柜内冲突记录。 */ + public Facts load() { + var available=candidates.availableOrders(); + var bookingIds=available.stream().map(ReceivingCandidateMapper.AvailableOrder::bookingId).distinct().toList(); + var orderRows=batch(bookingIds, ids->orders.selectList(new LambdaQueryWrapper() + .in(LastlegOrder::getSourceContainerBookingId,ids).isNotNull(LastlegOrder::getContainerNo))) + .stream().collect(Collectors.toMap(LastlegOrder::getId,Function.identity(),(left,right)->left)) + .values().stream().sorted(Comparator.comparing(LastlegOrder::getSourceContainerBookingId) + .thenComparing(LastlegOrder::getId)).toList(); + var lineRows=batch(orderRows.stream().map(LastlegOrder::getId).toList(), ids->lines.selectList( + new LambdaQueryWrapper().in(InboundPlanLine::getOrderId,ids))) + .stream().sorted(Comparator.comparing(InboundPlanLine::getId)).toList(); + var planIds=lineRows.stream().map(InboundPlanLine::getPlanId).filter(Objects::nonNull).distinct().toList(); + var planRows=batch(planIds, ids->plans.selectList(new LambdaQueryWrapper().in(InboundPlan::getId,ids))); + var unconfirmed=new HashSet<>(batch(planIds,candidates::unconfirmedPlans)); + var received=batch(lineRows.stream().map(InboundPlanLine::getId).toList(),candidates::received).stream() + .collect(Collectors.toMap(r->new LineKey(r.customerId(),r.planId(),r.lineId()),ReceivingCandidateMapper.Received::received)); + return new Facts(orderRows,planRows,lineRows,available.stream().map(ReceivingCandidateMapper.AvailableOrder::orderId) + .collect(Collectors.toSet()),unconfirmed,received); + } + + /** 固定大小分块,空范围不执行 SQL,也不截断候选。 */ + private static List batch(List ids,Function,List> query) { + var result=new ArrayList(); + for(int start=0;start candidates(String operatorKey, long overseasWarehouseId) { - ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + ReceivingContainerSession current = candidateRepository.currentSession(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); - List orderFacts = orders.selectList(new LambdaQueryWrapper() - .isNotNull(LastlegOrder::getSourceContainerBookingId) - .isNotNull(LastlegOrder::getContainerNo) - .orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId)); - List planFacts = plans.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlan::getId)); - List lineFacts = lines.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlanLine::getId)); + var facts = candidateRepository.load(); + List orderFacts = facts.orders(); + Map> linesByOrder = facts.lines().stream() + .collect(Collectors.groupingBy(InboundPlanLine::getOrderId)); + Map plansById = facts.plans().stream() + .collect(Collectors.toMap(InboundPlan::getId, Function.identity())); Map> grouped = orderFacts.stream() .filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null) .collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList())); String operatorLockKey = activeOperatorKey(operatorKey); - Set reservedByOthers = sessions.findActiveContainers().stream() + Set reservedByOthers = candidateRepository.activeSessions().stream() .filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey())) .map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet()); return grouped.entrySet().stream() - .map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId)) + .map(entry -> { + var localLines = entry.getValue().stream().flatMap(order -> + linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList(); + var localPlans = localLines.stream().map(line -> plansById.get(line.getPlanId())) + .filter(Objects::nonNull).distinct().toList(); + return readiness(entry.getKey(), entry.getValue(), localPlans, localLines, overseasWarehouseId, facts); + }) .filter(Objects::nonNull) .filter(ContainerReadiness::shippingReady) .filter(value -> !value.allPlansCompleted()) @@ -679,9 +688,17 @@ public class ReceivingContainerService { } } + /** 写命令和详情继续即时复验原资源与船节点。 */ private ContainerReadiness readiness(String sourceContainerBookingId, List orderFacts, List planFacts, List lineFacts, long overseasWarehouseId) { + return readiness(sourceContainerBookingId, orderFacts, planFacts, lineFacts, overseasWarehouseId, null); + } + + /** 候选使用请求内批量事实;其他入口保留即时校验,避免改变写命令并发门禁。 */ + private ContainerReadiness readiness(String sourceContainerBookingId, List orderFacts, + List planFacts, List lineFacts, + long overseasWarehouseId, ReceivingCandidateRepository.Facts facts) { Set orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet()); Map> linesByOrder = lineFacts.stream() .filter(line -> orderIds.contains(line.getOrderId())) @@ -718,9 +735,12 @@ public class ReceivingContainerService { boolean allPlansCompleted = !matchedPlans.isEmpty() && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); - boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; + boolean shippingReady = facts == null + ? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1 + : facts.availableOrderIds().contains(orderFacts.get(0).getId()); boolean resourcesConfirmed = matchedPlans.stream().noneMatch(plan -> - plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0); + facts == null ? plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0 + : facts.unconfirmedPlans().contains(plan.getId())); boolean operable = allPlansReady && resourcesConfirmed && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent @@ -740,7 +760,9 @@ public class ReceivingContainerService { List matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId())) .map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId()))) .filter(ref -> ref.plan() != null && ref.order() != null).toList(); - List inboundValues = matchedRefs.stream().map(this::inbound).toList(); + List inboundValues = matchedRefs.stream().map(ref -> facts == null + ? inbound(ref) : inbound(ref, facts.received().getOrDefault(new ReceivingCandidateRepository.LineKey( + ref.order().getCustomerId(), ref.plan().getId(), ref.line().getId()), 0))).toList(); List destinationCodes = inboundValues.stream().map(this::destinationCode) .filter(Objects::nonNull).distinct().sorted().toList(); Totals totals = new Totals(inboundValues.size(), destinationCodes, @@ -1152,12 +1174,18 @@ public class ReceivingContainerService { return sessions.findActiveByContainer(activeContainerKey(containerNo)); } + /** 单柜详情和写命令即时读取本货件实收量。 */ private ReceivingContainerResponses.Inbound inbound(InboundRef ref) { int received = allocations.selectList(new LambdaQueryWrapper() .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + return inbound(ref, received); + } + + /** 共享相同展示投影,候选可使用批量汇总的实收量。 */ + private ReceivingContainerResponses.Inbound inbound(InboundRef ref, int received) { int total = value(ref.line().getRequiredPackageCount()); return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java new file mode 100644 index 000000000..1a5386a80 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java @@ -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); + } + +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 67cfba200..4f2d8ac9e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -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()); + var planRows = plans.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()); + var lineRows = lines.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()); + 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(); + 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()) + .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"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index c867ccdb6..48402799f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -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);} } diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 7a2395d42..56d09c370 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,16 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", + "reason": "转运测量完成前补充预报箱,在同一锁单事务按客户及关联订单同步该转运自建计划行的预报箱数;仅批准这两个既有类型引用,不开放其他入仓实现或实收库存写入。" + }, + { + "source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper", + "reason": "转运测量完成前补充预报箱,在同一锁单事务按客户及关联订单同步该转运自建计划行的预报箱数;仅批准这两个既有类型引用,不开放其他入仓实现或实收库存写入。" + }, { "source": "com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService", "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity",