perf(lastleg): batch receiving candidates and trace slow work invalidation
This commit is contained in:
+6
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
|
||||
+35
@@ -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<AvailableOrder> availableOrders();
|
||||
|
||||
record AvailableOrder(Long orderId, String bookingId) { }
|
||||
|
||||
/** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */
|
||||
@Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " +
|
||||
"AND o.id=p.source_order_id WHERE p.id IN " +
|
||||
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> AND " +
|
||||
ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + "</script>")
|
||||
List<Long> unconfirmedPlans(@Param("ids") List<Long> ids);
|
||||
|
||||
/** 实收始终按客户、计划及计划行三重身份汇总。 */
|
||||
@Select("<script>SELECT customer_id,plan_id,line_id,COALESCE(SUM(picked_package_count),0) AS received " +
|
||||
"FROM ll_inbound_plan_pallet_allocation WHERE line_id IN " +
|
||||
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"GROUP BY customer_id,plan_id,line_id</script>")
|
||||
List<Received> received(@Param("ids") List<Long> ids);
|
||||
|
||||
record Received(String customerId, Long planId, Long lineId, int received) { }
|
||||
}
|
||||
+76
@@ -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<ReceivingContainerSession> activeSessions() {
|
||||
return sessions.findActiveContainers();
|
||||
}
|
||||
|
||||
public record LineKey(String customerId, Long planId, Long lineId) { }
|
||||
public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
|
||||
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> 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<LastlegOrder>()
|
||||
.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<InboundPlanLine>().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<InboundPlan>().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 <I,O> List<O> batch(List<I> ids,Function<List<I>,List<O>> query) {
|
||||
var result=new ArrayList<O>();
|
||||
for(int start=0;start<ids.size();start+=CHUNK_SIZE)
|
||||
result.addAll(query.apply(ids.subList(start,Math.min(ids.size(),start+CHUNK_SIZE))));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+40
-12
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
@@ -82,6 +83,7 @@ public class ReceivingContainerService {
|
||||
private static final String PALLET_CAPACITY_MESSAGE =
|
||||
"一个托盘最多只能收66箱,请拆分到其他托盘";
|
||||
|
||||
@Resource private ReceivingCandidateRepository candidateRepository;
|
||||
@Resource private InboundPlanMapper plans;
|
||||
@Resource private InboundPlanLineMapper lines;
|
||||
@Resource private InboundPlanPalletMapper pallets;
|
||||
@@ -119,24 +121,31 @@ public class ReceivingContainerService {
|
||||
Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName());
|
||||
}
|
||||
|
||||
/** 批量读取候选事实,柜内客户、仓库一致性和排序仍按原规则判断。 */
|
||||
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
|
||||
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
|
||||
ReceivingContainerSession current = candidateRepository.currentSession(activeOperatorKey(operatorKey));
|
||||
requireSessionWarehouse(current, overseasWarehouseId);
|
||||
List<LastlegOrder> orderFacts = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.isNotNull(LastlegOrder::getSourceContainerBookingId)
|
||||
.isNotNull(LastlegOrder::getContainerNo)
|
||||
.orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId));
|
||||
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>().orderByAsc(InboundPlan::getId));
|
||||
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>().orderByAsc(InboundPlanLine::getId));
|
||||
var facts = candidateRepository.load();
|
||||
List<LastlegOrder> orderFacts = facts.orders();
|
||||
Map<Long, List<InboundPlanLine>> linesByOrder = facts.lines().stream()
|
||||
.collect(Collectors.groupingBy(InboundPlanLine::getOrderId));
|
||||
Map<Long, InboundPlan> plansById = facts.plans().stream()
|
||||
.collect(Collectors.toMap(InboundPlan::getId, Function.identity()));
|
||||
Map<String, List<LastlegOrder>> 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<String> reservedByOthers = sessions.findActiveContainers().stream()
|
||||
Set<String> 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<LastlegOrder> orderFacts,
|
||||
List<InboundPlan> planFacts, List<InboundPlanLine> lineFacts,
|
||||
long overseasWarehouseId) {
|
||||
return readiness(sourceContainerBookingId, orderFacts, planFacts, lineFacts, overseasWarehouseId, null);
|
||||
}
|
||||
|
||||
/** 候选使用请求内批量事实;其他入口保留即时校验,避免改变写命令并发门禁。 */
|
||||
private ContainerReadiness readiness(String sourceContainerBookingId, List<LastlegOrder> orderFacts,
|
||||
List<InboundPlan> planFacts, List<InboundPlanLine> lineFacts,
|
||||
long overseasWarehouseId, ReceivingCandidateRepository.Facts facts) {
|
||||
Set<Long> orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet());
|
||||
Map<Long, List<InboundPlanLine>> 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<InboundRef> 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<ReceivingContainerResponses.Inbound> inboundValues = matchedRefs.stream().map(this::inbound).toList();
|
||||
List<ReceivingContainerResponses.Inbound> 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<String> 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<InboundPlanPalletAllocation>()
|
||||
.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()
|
||||
|
||||
+130
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -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")));
|
||||
|
||||
+2
-1
@@ -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);}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user