From b0acbfbb3486f6afa0d45e9b6f2b6af0d6023bb8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 12:54:39 +0800 Subject: [PATCH] perf(lastleg): batch appointment requirement generation reads --- .../AppointmentCargoReadRepository.java | 16 ++ .../AppointmentCargoVerificationService.java | 18 ++ .../AppointmentSourceReadRepository.java | 26 ++ .../AppointmentSourceService.java | 85 ++++++ .../AppointmentWorkFactAdapter.java | 4 +- .../AppointmentWorkFactGateway.java | 5 +- .../AppointmentWorkSyncService.java | 13 +- .../InboundFbaAppointmentQueryMapper.java | 22 ++ .../service/InboundFbaAppointmentService.java | 37 +++ .../AppointmentRequirementReadRepository.java | 28 ++ .../AppointmentWorkRequirementProvider.java | 102 ++++++-- ...AppointmentCargoVerificationBatchTest.java | 16 ++ .../AppointmentPlanningAuthorizationTest.java | 4 + .../AppointmentSourceServiceTest.java | 50 ++++ .../AppointmentWorkFactAdapterTest.java | 10 +- .../AppointmentWorkPersistenceTest.java | 2 +- .../AppointmentWorkSyncServiceTest.java | 48 +++- .../appointmentwork/WorkTestFixture.java | 2 +- .../FbaAppointmentPersistenceTest.java | 6 + ...ointmentRequirementReadRepositoryTest.java | 244 ++++++++++++++++++ ...ppointmentWorkRequirementProviderTest.java | 75 ++++-- 21 files changed, 747 insertions(+), 66 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java new file mode 100644 index 000000000..ebec3cdd6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 单次生成只批量读取核实与分配原料,业务解析仍按实际访问顺序。 */ +@Repository +public class AppointmentCargoReadRepository { + private final AppointmentCargoVerificationMapper mapper; + /** 复用本域既有批量查询。 */ + public AppointmentCargoReadRepository(AppointmentCargoVerificationMapper mapper) { this.mapper=mapper; } + /** 取得每客户来源的最新核实记录,暂不解析货件 JSON。 */ + public List latest(List refs) { return refs.isEmpty()?List.of():mapper.latestBatch(refs); } + /** 分配数量仅取活动明细,未知数量保持单独计数。 */ + public List allocations(List refs) { return refs.isEmpty()?List.of():mapper.allocatedBatch(refs); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java index e0bc99167..a05e33b7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -21,6 +21,7 @@ public class AppointmentCargoVerificationService { private final AppointmentSourceService sources; private final AppointmentCargoVerificationMapper mapper; private final ObjectMapper json; + @jakarta.annotation.Resource private AppointmentCargoReadRepository readRepository; public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, ObjectMapper json) { @@ -50,6 +51,23 @@ public class AppointmentCargoVerificationService { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { public Context requireContext() { if(error!=null) throw error; return context; } } + /** 只预取本次已授权来源的原料;核实 JSON 与数量判断按原 anyMatch 实际访问顺序执行。 */ + public java.util.function.Function prepareInspection(List resolved) { + var refs=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); + var latest=new HashMap,AppointmentCargoVerification>(); + var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); + for(int start=0;startlatest.put(List.of(r.getCustomerId(),r.getSourceKey()),r)); + readRepository.allocations(batch).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems()))); + } + var allowed=new HashSet<>(resolved); + return source->{ + if(!allowed.contains(source)) throw new IllegalArgumentException("货物核实来源不属于本次读取范围"); + var key=List.of(source.customerId(),source.sourceKey()); + return inspect(source,latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))); + }; + } /** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ public List inspectBatch(List refs) { var result=new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java new file mode 100644 index 000000000..7ff060a6e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 同次需求生成的来源原料读取,不保存跨调用状态。 */ +@Repository +public class AppointmentSourceReadRepository { + private final LastlegOrderMapper orders; + private final ContainerShippingGateMapper shipping; + private final AppointmentCoverageMapper destinations; + /** 仅组合本域既有批量持久查询。 */ + public AppointmentSourceReadRepository(LastlegOrderMapper orders,ContainerShippingGateMapper shipping,AppointmentCoverageMapper destinations) { + this.orders=orders;this.shipping=shipping;this.destinations=destinations; + } + /** 读取所选订单,客户归属继续由来源服务逐需求核验。 */ + public List orders(List ids) { return ids.isEmpty()?List.of():orders.selectBatchIds(ids); } + /** 读取实际船节点门禁,不从计划时间推断。 */ + public List gates(List ids) { return ids.isEmpty()?List.of():shipping.selectBatchIds(ids); } + /** 转运目的地仍取原业务权威关系。 */ + public List destinations(List refs) { return refs.isEmpty()?List.of():destinations.sources(refs); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index d2957eb37..91e1e3341 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -26,6 +26,7 @@ public class AppointmentSourceService { private final AppointmentWarehouseClient warehouses; private final String token; @jakarta.annotation.Resource private AppointmentCoverageMapper destinationFacts; + @jakarta.annotation.Resource private AppointmentSourceReadRepository readRepository; public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping, AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses, @Value("${baiyue.security.internal-service-token:}") String token) { @@ -72,12 +73,96 @@ public class AppointmentSourceService { public List resolveBatch(List requested) { return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList(); } + /** 同页只合并原料查询;来源规范化、重复与权限检查仍按每份需求独立执行。 */ + public List resolveOrderGroups(List> groups) { + var canonical=new java.util.ArrayList>(); + var errors=new java.util.ArrayList(); + var original=new LinkedHashSet(); + for(var group:groups) { + RuntimeException error=null; + if(group==null || group.isEmpty() || group.size()>100) error=new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Firstleg需求缺少完整客户货源关系"); + else if(group.stream().anyMatch(r->r==null || blank(r.customerId()) || r.orderId()==null || r.orderId()<=0)) error=bad("请选择完整的真实货源"); + errors.add(error);if(error==null) original.addAll(group); + } + var aliases=new java.util.HashMap(); + var originals=new java.util.ArrayList<>(original); + for(int start=0;start(); + var orderIds=new LinkedHashSet(); + for(int i=0;i{ + var alias=aliases.get(ref); + return new AppointmentCoverageService.SourceRef(ref.customerId(),alias==null?"ORDER:"+ref.orderId():"BOOKING:"+alias.bookingNo(),null); + }).toList(); + // 保留原 canonical 的数量校验,不能以全页去重掩盖单需求重复。 + if(refs.size()!=groups.get(i).size()) errors.set(i,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源键必须明确为ORDER或BOOKING")); + refs=refs.stream().distinct().toList();canonical.add(refs); + for(var ref:refs) if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey())); + else bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))); + } + var bookingMap=new java.util.HashMap(); + var bookings=new java.util.ArrayList<>(bookingRefs); + for(int start=0;start(); + var gates=new java.util.HashMap(); + var destinations=new java.util.HashMap(); + var ids=new java.util.ArrayList<>(orderIds); + for(int start=0;start{if(orderMap.put(row.getId(),row)!=null) throw new IllegalStateException("原订单来源重复");}); + } + var containers=orderMap.values().stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); + for(int start=0;start{ + if(gates.put(g.getContainerBookingId(),g.getNodeSequence()!=null && g.getNodeSequence()>=40)!=null) throw new IllegalStateException("原船节点门禁重复"); + }); + var transfers=orderMap.values().stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList(); + for(int start=0;start{ + if(destinations.put(d.orderId(),d)!=null) throw new IllegalStateException("原目的地来源重复"); + }); + var result=new java.util.ArrayList();RuntimeException infrastructure=null; + for(int i=0;i(); + if(error==null) error=infrastructure; + if(error==null) try { + var keys=new java.util.HashSet();var materialized=new java.util.HashSet(); + for(var ref:refs) { + if(blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源"); + if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择"); + } + var directory=catalog(); + for(var ref:refs) { + var source=resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,directory,destinations); + if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); + resolved.add(source); + } + } catch(RuntimeException failed) { + error=failed; + // 基础设施失败不重试、不变为空数据,延后到原需求访问位置抛出。 + if(!(failed instanceof ResponseStatusException status) || status.getStatusCode().value()>=500) infrastructure=failed; + } + result.add(new OrderGroup(refs,List.copyOf(resolved),error)); + } + return List.copyOf(result); + } /** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */ private List catalog() { if(directory==null) directory=AppointmentSourceService.this.catalog(); return directory; } } + public record OrderGroup(List references,List sources,RuntimeException error) { + /** 业务前置错误仍在原需求解析位置抛出,由原边界决定是否保留未就绪需求。 */ + public List requireSources() { if(error!=null) throw error;return sources; } + } /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { public Source requireSource() { if(error!=null) throw error; return source; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java index 7fc1d2530..0d8464783 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -117,7 +117,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); } } /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ - public RequirementReader openRequirements() { + public RequirementReader openRequirements(List inputs) { return new RequirementReader() { private RequirementReader reader; /** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */ @@ -125,7 +125,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { if(reader==null) { var provider=requirements.getIfAvailable(); if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); - reader=provider.openRequirements(); + reader=provider.openRequirements(inputs); } var result=reader.read(source,effectiveFlow); if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java index e2af776af..5912bbae3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -28,7 +28,7 @@ public interface AppointmentWorkFactGateway { SourcePage discover(String after, int size); ContainerSource container(String containerBookingId); /** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */ - RequirementReader openRequirements(); + RequirementReader openRequirements(List inputs); Map facts(List states); Owner owner(String id, Actor actor); Responsibility supplier(String id); @@ -43,5 +43,6 @@ public interface AppointmentWorkFactGateway { interface SupplierDirectory { Responsibility activeSupplier(String id); } /** 单次生成显式复用目录,不跨请求保存来源事实。 */ interface RequirementReader { List read(ContainerSource source,Flow effectiveFlow); } - interface RequirementProvider { RequirementReader openRequirements(); } + record RequirementInput(ContainerSource source,Flow flow) { } + interface RequirementProvider { RequirementReader openRequirements(List inputs); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index 0f22f79b7..f668a6fe6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -36,7 +36,16 @@ public class AppointmentWorkSyncService { timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); - var requirementReader=gateway.openRequirements(); + var requirementInputs=page.containers().stream().filter(source->{ + // 坏来源仍由下面原顺序校验并报错,不能在后页原料提示阶段抢先覆盖前柜错误。 + try { validateSource(source);return true; } catch(RuntimeException invalid) { return false; } + }).map(source->{ + var flow=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))) + .filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(source.establishedFlow()); + return new RequirementInput(source,flow); + }).filter(input->input.flow()!=Flow.UNKNOWN).toList(); + // 提示仅用于原料批量读取,下面逐柜 existing/activeFlow 仍根据最新工作集决定。 + var requirementReader=gateway.openRequirements(requirementInputs); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); @@ -131,7 +140,7 @@ public class AppointmentWorkSyncService { } /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { - return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements()); + return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements(List.of(new RequirementInput(source,flow)))); } /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ private Map workingSet() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 216503c41..7a116f3f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -28,6 +28,28 @@ public interface InboundFbaAppointmentQueryMapper { " ORDER BY appointment_id DESC LIMIT 1001"}) List relatedBatchIds(@Param("sources") List sources); + record RequirementSource(int groupOrdinal,String customerId,String sourceKey,String destinationKey) { } + record RequirementBatch(int groupOrdinal,long appointmentId) { } + /** 按原需求组返回历史归属,失活明细也保留;每组第1001条用于检测超限。 */ + @Select(""" + + """) + List requirementBatches(@Param("sources") List sources); + @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index cec90b860..30b1f5498 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -34,6 +34,7 @@ public class InboundFbaAppointmentService { @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork; @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges; + @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads; @Transactional(readOnly=true) public List calendar(LocalDate from,LocalDate to) { @@ -111,6 +112,42 @@ public class InboundFbaAppointmentService { if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"); return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids)); } + /** 同页原料只读一次,历史归属按原需求组保留;响应解析延后至原访问顺序。 */ + public java.util.function.IntFunction> prepareRelatedBatchSnapshots(List> groups) { + var errors=new HashMap(); + var requested=new ArrayList(); + for(int i=0;i100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) { + errors.put(i,bad("每次最多读取100个完整原来源的关联批次"));continue; + } + final int group=i; + refs.stream().distinct().forEach(ref->requested.add(new InboundFbaAppointmentQueryMapper.RequirementSource(group,ref.customerId(),ref.sourceKey(),ref.destinationKey()))); + } + var groupIds=new HashMap>(); + for(int start=0;start=groups.size()) throw new IllegalStateException("历史关联返回了请求外需求组"); + groupIds.computeIfAbsent(match.groupOrdinal(),ignored->new TreeSet<>()).add(match.appointmentId()); + } + } + groupIds.forEach((group,ids)->{if(ids.size()>1000) errors.put(group,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"));}); + var ids=groupIds.entrySet().stream().filter(e->!errors.containsKey(e.getKey())).flatMap(e->e.getValue().stream()).distinct().sorted().toList(); + var rows=new LinkedHashMap(); + var items=new HashMap>(); + for(int start=0;startrows.put(row.getId(),row)); + requirementReads.items(batch).forEach(item->items.computeIfAbsent(item.getAppointmentId(),ignored->new ArrayList<>()).add(item)); + } + return group->{ + if(group<0 || group>=groups.size()) throw new IllegalArgumentException("需求组不属于本次读取范围"); + if(errors.containsKey(group)) throw errors.get(group); + var selected=groupIds.getOrDefault(group,Set.of()); + return rows.values().stream().filter(row->selected.contains(row.getId())).map(row->response(row,items.getOrDefault(row.getId(),List.of()))).toList(); + }; + } @Transactional(readOnly=true) public FbaAppointmentBatchContext batchContext(String key) { final long id; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java new file mode 100644 index 000000000..c25768b2e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.mapper.*; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 需求生成的本域原料读取,历史归属不能从活动响应明细反推。 */ +@Repository +public class AppointmentRequirementReadRepository { + private final AppointmentWorkSourceMapper mapping; + private final InboundFbaAppointmentQueryMapper queries; + private final InboundFbaAppointmentMapper appointments; + private final InboundFbaAppointmentItemMapper items; + /** 保留本域 Mapper 与客户来源查询边界。 */ + public AppointmentRequirementReadRepository(AppointmentWorkSourceMapper mapping,InboundFbaAppointmentQueryMapper queries,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items) { + this.mapping=mapping;this.queries=queries;this.appointments=appointments;this.items=items; + } + /** 每柜独立上限由既有窗口查询保持。 */ + public List sources(List containers) { return containers.isEmpty()?List.of():mapping.containerSourcesBatch(containers); } + /** 分块只控制单条 SQL 输入,业务服务仍按需求组完整合并及核对上限。 */ + public List history(List sources) { return sources.isEmpty()?List.of():queries.requirementBatches(sources); } + /** 读取已确认关联的预约主记录。 */ + public List appointments(List ids) { return ids.isEmpty()?List.of():appointments.selectBatchIds(ids); } + /** 包括失活明细以维持取消与替代历史;响应过滤由业务服务执行。 */ + public List items(List ids) { return ids.isEmpty()?List.of():items.selectList(new LambdaQueryWrapper().in(InboundFbaAppointmentItem::getAppointmentId,ids).orderByAsc(InboundFbaAppointmentItem::getId)); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java index db8e65d91..6bab0f177 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -17,29 +17,85 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa private final AppointmentCargoVerificationService cargo; private final InboundFbaAppointmentService appointments; private final AppointmentWorkSourceMapper sourceMapping; + @jakarta.annotation.Resource private AppointmentRequirementReadRepository reads; public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; } - /** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */ - public RequirementReader openRequirements() { + /** 本次生成共享原料,实例不会跨同步页或独立命令保存。 */ + public RequirementReader openRequirements(List inputs) { return new RequirementReader() { - private AppointmentSourceService.ReadScope scope; - /** 直送和未建立尾程来源的需求不创建目录读取依赖。 */ + private Prepared prepared; + /** 原料提示不替代实际路线判断;未提示的真实需求按同一实现即时读取。 */ public List read(ContainerSource container,Flow flow) { - return requirements(container,flow,refs->{ - if(scope==null) scope=sources.openReadScope(); - return scope.resolveBatch(refs); - }); + if(flow==Flow.DIRECT) return original(container); + if(flow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); + if(prepared==null) { + var requested=new ArrayList<>(inputs);requested.add(new RequirementInput(container,flow)); + prepared=prepare(requested); + } + var data=prepared.containers.contains(container)?prepared:prepare(List.of(new RequirementInput(container,flow))); + return requirements(container,flow,data); } }; } + /** 私人地址仍以原客户仓需求表示,直送不增加尾程读取依赖。 */ + private List original(ContainerSource container) { + return container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); + } + private record Group(ContainerSource container,String requirementKey) { } + /** 只含本次原料及延后执行器,不缓存已经解析的业务响应。 */ + private static final class Prepared { + private final Set containers=new LinkedHashSet<>(); + private final Map> mapped=new HashMap<>(); + private final Map ordinals=new LinkedHashMap<>(); + private List sources=List.of(); + private RuntimeException mappingError,sourceError; + private java.util.function.IntFunction> history; + private java.util.function.Function inspection; + } + /** 先批量读取原料,组级业务错误在原需求访问位置抛出。 */ + private Prepared prepare(List inputs) { + var data=new Prepared(); + inputs.stream().filter(i->i.flow()==Flow.TRANSFER).map(RequirementInput::source).forEach(data.containers::add); + var ids=data.containers.stream().map(ContainerSource::containerBookingId).distinct().toList(); + try { + for(int start=0;startdata.mapped.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.source())); + } catch(RuntimeException error) { data.mappingError=error;return data; } + var groups=new ArrayList>(); + for(var container:data.containers) { + var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); + if(mapped.size()>1000) continue; + for(var requirement:original(container)) { + if(!"FBA".equals(requirement.destinationKind())) continue; + var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null; + var orders=mapped.stream().filter(row->Objects.equals(row.sourceInboundId(),inbound)).toList(); + if(orders.isEmpty()) continue; + data.ordinals.put(new Group(container,requirement.requirementKey()),groups.size()); + groups.add(orders.stream().map(row->new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.id())).toList()); + } + } + if(groups.isEmpty()) return data; + try { data.sources=sources.openReadScope().resolveOrderGroups(groups); } + catch(RuntimeException error) { data.sourceError=error;return data; } + var historyGroups=data.sources.stream().map(group->group.error()==null?group.sources().stream().map(source->new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList():List.of()).toList(); + var valid=data.sources.stream().filter(group->group.error()==null).flatMap(group->group.sources().stream()).toList(); + if(valid.isEmpty()) return data; + try { data.history=appointments.prepareRelatedBatchSnapshots(historyGroups); } + catch(RuntimeException error) { data.history=ignored->{throw error;}; } + // 不提前解析货件,仍由原 history -> anyMatch 路径按实际访问顺序执行。 + try { data.inspection=cargo.prepareInspection(valid); } + catch(RuntimeException error) { data.inspection=ignored->{throw error;}; } + return data; + } /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ private List requirements(ContainerSource container,Flow effectiveFlow, - java.util.function.Function,List> resolver) { - var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); + Prepared data) { + var original=original(container); if(effectiveFlow==Flow.DIRECT) return original; if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); - var mapped=sourceMapping.containerSources(container.containerBookingId()); + if(data.mappingError!=null) throw data.mappingError; + var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); var result=new LinkedHashMap(); var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.of(); @@ -50,9 +106,13 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; } if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; } if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } - var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); + if(sourceOrders.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); + if(data.sourceError!=null) throw data.sourceError; + int ordinal=data.ordinals.get(new Group(container,requirement.requirementKey())); + var group=data.sources.get(ordinal); + var refs=group.references(); List resolved; - try { resolved=resolver.apply(refs); } + try { resolved=group.requireSources(); } catch(org.springframework.web.server.ResponseStatusException error) { if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; // Keep the complete physical obligation and its canonical source identities. The fact @@ -63,10 +123,10 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa continue; } var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList(); - var history=appointments.relatedBatchSnapshots(complete); + var history=data.history.apply(ordinal); var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList(); // Unknown remaining quantity is still work, never a zero or an absent requirement. - boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); + boolean remainder=resolved.stream().map(data.inspection).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); if(remainder) { var restored=new LinkedHashSet<>(replaced); @@ -128,18 +188,6 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList())); } } - private List canonical(List input) { - if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); - var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{ - try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); } - catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); } - }).toList(); - var result=new ArrayList(); - if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders)); - input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add); - if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); - return result.stream().distinct().toList(); - } /** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */ private static boolean active(FbaAppointmentResponse batch) { if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java index ea89cc6e8..8eced830c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java @@ -11,6 +11,22 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentCargoVerificationBatchTest { + /** 预取只读原料,未被 anyMatch 访问的坏 JSON 不提前报错;下一次准备仍重新读取。 */ + @Test void preparedAuthorizedSourcesKeepShortCircuitAndBoundedReads() { + var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); + var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); + org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentCargoReadRepository(mapper)); + var resolved=IntStream.rangeClosed(1,201).mapToObj(i->new AppointmentSourceService.Source("ORDER:"+i,"C",(long)i,"IN",null,"CNT","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())).toList(); + var broken=new AppointmentCargoVerification();broken.setCustomerId("C");broken.setSourceKey("ORDER:2");broken.setPackageCount(10);broken.setShipmentReferencesJson("broken-json"); + when(mapper.latestBatch(anyList())).thenAnswer(c->c.>getArgument(0).stream().anyMatch(r->r.sourceKey().equals("ORDER:2"))?List.of(broken):List.of()); + var read=service.prepareInspection(resolved); + assertTrue(resolved.stream().map(read).anyMatch(c->c.availablePackages()!=null && c.availablePackages()>0)); + assertThrows(RuntimeException.class,()->read.apply(resolved.get(1))); + assertEquals(10L,read.apply(resolved.get(200)).availablePackages()); + verify(mapper,times(3)).latestBatch(anyList());verify(mapper,times(3)).allocatedBatch(anyList());verifyNoInteractions(sources); + service.prepareInspection(resolved);verify(mapper,times(6)).latestBatch(anyList());verify(mapper,times(6)).allocatedBatch(anyList()); + assertThrows(IllegalArgumentException.class,()->read.apply(new AppointmentSourceService.Source("ORDER:1","OTHER",1L,null,null,null,"100",null,"FBA:ONT8","ONT8",10,"v1",true,List.of()))); + } @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 13e21e691..be51c5bf3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -146,6 +146,10 @@ class AppointmentPlanningAuthorizationTest { @Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);} @Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); } @Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); } + /** 权限测试只模拟业务服务,补齐其新增只读端口依赖而不触发数据库。 */ + @Bean AppointmentSourceReadRepository sourceReads() { return mock(AppointmentSourceReadRepository.class); } + /** 本测试验证入口权限,不执行货物核实原料读取。 */ + @Bean AppointmentCargoReadRepository cargoReads() { return mock(AppointmentCargoReadRepository.class); } @Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); } @Bean AppointmentBatchPreviewController batchPreviewController(AppointmentBatchPreviewService service) { return new AppointmentBatchPreviewController(service); } @Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java index 3082da1b6..ee5ff8d4d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -12,6 +12,56 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentSourceServiceTest { + /** 同页共享原料但保留逐组权限、重复和前置错误,结果等于各组独立严格读取。 */ + @Test void groupedOrderResolutionSharesReadsWithoutSharingValidationOrFailures() { + var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); + var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); + var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); + org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); + 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("C2");b.setOverseasWarehouseId("100");b.setDestinationType("FBA");b.setFbaWarehouseCode("ONT8");b.setPackageCount(20); + when(orders.selectBatchIds(anyCollection())).thenAnswer(c->List.of(a,b).stream().filter(o->c.>getArgument(0).contains(o.getId())).toList()); + var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40); + when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate)); + 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 ref=new AppointmentExternalSourceGateway.OrderRef("A",1L); + var groups=List.of(List.of(ref),List.of(ref),List.of(new AppointmentExternalSourceGateway.OrderRef("B",2L)),List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)),List.of(ref,ref)); + var actual=service.openReadScope().resolveOrderGroups(groups); + assertEquals(actual.get(0).requireSources(),actual.get(1).requireSources()); + assertEquals("货物尚无已开船或后续实际船节点",((ResponseStatusException)actual.get(2).error()).getReason()); + assertEquals(404,((ResponseStatusException)actual.get(3).error()).getStatusCode().value()); + assertEquals(503,((ResponseStatusException)actual.get(4).error()).getStatusCode().value()); + assertEquals("来源键必须明确为ORDER或BOOKING",((ResponseStatusException)actual.get(4).error()).getReason()); + verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(external,times(1)).resolveOrders(anyList());verify(warehouse,times(1)).catalog("token"); + assertEquals(service.resolveBatch(actual.get(0).references()),actual.get(0).requireSources()); + assertEquals(400,((ResponseStatusException)service.openReadScope().resolveOrderGroups(List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("x".repeat(129),1L)))).get(0).error()).getStatusCode().value()); + var failure=new IllegalStateException("directory unavailable");when(warehouse.catalog("token")).thenThrow(failure); + clearInvocations(warehouse); + var failed=service.openReadScope().resolveOrderGroups(List.of(List.of(ref),List.of(ref))); + assertEquals(503,((ResponseStatusException)failed.get(0).error()).getStatusCode().value());assertSame(failed.get(0).error(),failed.get(1).error());verify(warehouse,times(1)).catalog("token"); + service.openReadScope().resolveOrderGroups(List.of(List.of(ref)));verify(warehouse,times(2)).catalog("token"); + } + /** 不同订单规范化成同登记仍合一,不同登记重复同实物订单仍拒绝。 */ + @Test void groupedCanonicalAliasesKeepOriginalDistinctAndMaterializedRules() { + var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); + var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); + org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); + var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10); + when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order)); + 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 booking=new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",1L,10,1L); + when(external.resolveOrders(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",2L,10,1L))); + when(external.resolve(anyList())).thenReturn(List.of(booking)); + var input=List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L),new AppointmentExternalSourceGateway.OrderRef("A",2L))); + assertEquals(1,service.openReadScope().resolveOrderGroups(input).get(0).requireSources().size()); + var alias2=new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",2L,10,1L); + when(external.resolveOrders(anyList())).thenReturn(List.of(booking,alias2)); + when(external.resolve(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); + assertEquals("同一货物存在重复来源,请保留原登记来源",((ResponseStatusException)service.openReadScope().resolveOrderGroups(input).get(0).error()).getReason()); + when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","x".repeat(161),null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); + var invalid=service.openReadScope().resolveOrderGroups(List.of(input.get(0).subList(0,1))).get(0); + assertEquals("请选择完整的真实货源",((ResponseStatusException)invalid.error()).getReason()); + } /** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */ @Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() { var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java index f3b02809a..dc08f4468 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -103,10 +103,10 @@ class AppointmentWorkFactAdapterTest { var business=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); org.mockito.Mockito.when(business.orderedStream()).thenAnswer(c->java.util.stream.Stream.empty()); var adapter=new AppointmentWorkFactAdapter(null,business,null,providers,null,"token"); - var unused=adapter.openRequirements();org.mockito.Mockito.verifyNoInteractions(providers); + var unused=adapter.openRequirements(java.util.List.of());org.mockito.Mockito.verifyNoInteractions(providers); var source=new ContainerSource("1","CNT",java.time.Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of()); var f=new WorkTestFixture(); - org.mockito.Mockito.when(f.gateway.openRequirements()).thenAnswer(c->adapter.openRequirements()); + org.mockito.Mockito.when(f.gateway.openRequirements(java.util.List.of())).thenAnswer(c->adapter.openRequirements(java.util.List.of())); org.mockito.Mockito.when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.sync.synchronize(new SyncRequest(null,10));assertEquals(Type.ROUTE,f.store.all().get(0).getType()); org.mockito.Mockito.verifyNoInteractions(providers); @@ -114,14 +114,14 @@ class AppointmentWorkFactAdapterTest { var provider=org.mockito.Mockito.mock(RequirementProvider.class); var delegate=org.mockito.Mockito.mock(RequirementReader.class); org.mockito.Mockito.when(providers.getIfAvailable()).thenReturn(provider); - org.mockito.Mockito.when(provider.openRequirements()).thenReturn(delegate); + org.mockito.Mockito.when(provider.openRequirements(java.util.List.of())).thenReturn(delegate); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); org.mockito.Mockito.when(delegate.read(source,Flow.TRANSFER)).thenReturn(List.of(requirement),List.of(requirement),List.of()); - var reader=adapter.openRequirements(); + var reader=adapter.openRequirements(java.util.List.of()); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertThrows(ResponseStatusException.class,()->reader.read(source,Flow.TRANSFER)); - org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(); + org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(java.util.List.of()); } @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java index af142038b..172367892 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -79,7 +79,7 @@ class AppointmentWorkPersistenceTest { var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8"))); var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement)); var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class); - org.mockito.Mockito.when(facts.openRequirements()).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); + org.mockito.Mockito.when(facts.openRequirements(org.mockito.ArgumentMatchers.anyList())).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR)); assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate()); var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index 6ac989b15..670828b74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -12,7 +12,7 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkSyncServiceTest { - /** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */ + /** 百柜同页批量读取来源原料,下页与独立命令重读,生成状态保持不变。 */ @Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() { var f=new WorkTestFixture(); var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); @@ -31,6 +31,7 @@ class AppointmentWorkSyncServiceTest { var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId(id);gate.setNodeSequence(40);return gate; }).toList()); var sources=new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService(orders,shipping,external,warehouse,"token"); + org.springframework.test.util.ReflectionTestUtils.setField(sources,"readRepository",new com.baiyue.lastleg.appointmentplanning.AppointmentSourceReadRepository(orders,shipping,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.class))); var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); var allocation=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.Context.class); when(allocation.availablePackages()).thenReturn(1L);when(cargo.inspect(any(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source.class))).thenReturn(allocation); @@ -39,13 +40,18 @@ class AppointmentWorkSyncServiceTest { new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",1));}); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo, mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class),mapping); - when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); + wireBatches(provider,cargo,mapping); + when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); var rows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(i->new ContainerSource(""+i,"CNT"+i, Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null, List.of(new Requirement("INBOUND:"+i,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(1)).catalog("token"); - verify(orders,times(100)).selectBatchIds(anyCollection()); + verify(orders,times(1)).selectBatchIds(anyCollection()); + verify(external,times(1)).resolveOrders(anyList()); + var reads=(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository)org.springframework.test.util.ReflectionTestUtils.getField(provider,"reads"); + verify(reads,times(1)).sources(argThat(ids->ids.size()==100 && new HashSet<>(ids).size()==100)); + verify(cargo,times(1)).prepareInspection(argThat(values->values.size()==100)); var before=f.store.all().stream().map(f.store::write).toList(); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(2)).catalog("token"); assertEquals(before,f.store.all().stream().map(f.store::write).toList()); @@ -53,7 +59,27 @@ class AppointmentWorkSyncServiceTest { verify(warehouse,times(3)).catalog("token"); f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); verify(warehouse,times(4)).catalog("token"); - verify(orders,times(202)).selectBatchIds(anyCollection()); + verify(orders,times(4)).selectBatchIds(anyCollection()); + verify(reads,times(4)).sources(anyList());verify(cargo,times(4)).prepareInspection(anyList()); + } + + /** 预取提示不能抢先解析后方坏来源,原循环仍拒绝且不能发布扫描完成。 */ + @Test void invalidSourcesRemainOriginalOrderedFailuresInsteadOfBeingIgnoredByPreparation() { + var valid=new ContainerSource("1","CNT",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()))); + var missingId=new ContainerSource(null,"CNT",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,valid.requirements()); + var missingRequirements=new ContainerSource("2","CNT2",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,null); + for(var rows:List.of(java.util.Arrays.asList((ContainerSource)null),List.of(missingId),java.util.Arrays.asList(valid,null),List.of(valid,missingRequirements))) { + var f=new WorkTestFixture(); + when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); + when(f.gateway.openRequirements(anyList())).thenAnswer(c->{ + var hints=c.>getArgument(0); + assertEquals(rows.get(0)==valid?List.of(new RequirementInput(valid,Flow.TRANSFER)):List.of(),hints); + return (RequirementReader)(source,flow)->source.requirements(); + }); + var error=assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,100))); + assertEquals(503,error.getStatusCode().value());assertEquals("已开船来源缺少身份或真实开船依据",error.getReason()); + verify(f.mapper,never()).saveGuard(any()); + } } /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ @@ -103,8 +129,9 @@ class AppointmentWorkSyncServiceTest { var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); var scope=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.ReadScope.class); when(sources.openReadScope()).thenReturn(scope); - when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); + when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{var r=sources.canonicalOrders(group);return new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.OrderGroup(r,sources.resolveBatch(r),null);}).toList()); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + wireBatches(provider,cargo,mapping); when(mapping.containerSources("1")).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(1L,"A","1","1","FBA","ONT8",1),new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(2L,"B","2","1","FBA","ONT8",1))); when(sources.canonicalOrders(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList()); when(sources.resolveBatch(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source(r.sourceKey(),r.customerId(),Long.valueOf(r.sourceKey().substring(6)),null,null,"CNT",null,null,r.destinationKey(),"ONT8",1,"v1",true,List.of())).toList()); @@ -117,7 +144,7 @@ class AppointmentWorkSyncServiceTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(snapshot)); var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),refs); var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root)); - when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); + when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.guard.setRequirementVersion(1); f.sync.synchronize(new SyncRequest(null,10)); @@ -373,4 +400,13 @@ class AppointmentWorkSyncServiceTest { assertFalse(event.getFormattedMessage().contains("private-source-detail"));assertNull(event.getThrowableProxy()); } finally { logger.detachAppender(appender);appender.stop(); } } + /** 用既有原事实模拟组装回归,百柜来源查询由真实来源服务计数验证。 */ + private static void wireBatches(com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider provider,com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService cargo,com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper mapping) { + var reads=mock(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository.class); + when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); + org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); + when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); + var appointments=(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService)org.springframework.test.util.ReflectionTestUtils.getField(provider,"appointments"); + when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java index bc953d55a..e621910b4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java @@ -38,7 +38,7 @@ final class WorkTestFixture { when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap(); for(var state:c.>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; }); when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope")); when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲")); - when(gateway.openRequirements()).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); + when(gateway.openRequirements(anyList())).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); } AppointmentWorkState state(String container,Type type,Flow flow,String requirement) { var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container); 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 48402799f..9471a3521 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 @@ -259,6 +259,12 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;} @Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();} @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} + /** 新增只读原料端口仍接实际 Mapper;本测试未涉及船节点查询。 */ + @Bean AppointmentSourceReadRepository sourceReads(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,AppointmentCoverageMapper facts){return new AppointmentSourceReadRepository(orders,mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class),facts);} + /** 核实原料沿真实持久 Mapper 读取,不替代事务断言。 */ + @Bean AppointmentCargoReadRepository cargoReads(AppointmentCargoVerificationMapper mapper){return new AppointmentCargoReadRepository(mapper);} + /** 历史预约原料沿本测试真实主单、明细及查询 Mapper 读取。 */ + @Bean com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads(InboundFbaAppointmentQueryMapper query,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items){return new com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository(mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class),query,appointments,items);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java new file mode 100644 index 000000000..36e69f704 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java @@ -0,0 +1,244 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper.RequirementSource; +import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.ArrayList; +import java.util.Collections; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AppointmentRequirementReadRepositoryTest { + /** 关联依据是原客户、来源及目的仓,不能因失活、取消或响应明细变空而消失。 */ + @Test + void historyKeepsInactiveAndReplacedAssociationsWithoutCrossCustomerOrDestinationLeak() throws Exception { + try (var fixture=new Fixture()) { + fixture.appointment(10,"CANCELLED",null,null); + fixture.appointment(20,"CANCELLED","[\"AP-30\"]","[\"FBA_APPOINTMENT:30\"]"); + fixture.appointment(30,"PENDING",null,null); + fixture.item(101,10,"A","ORDER:1","ABE4",false); + fixture.item(201,20,"A","ORDER:1","ABE4",false); + fixture.item(301,30,"A","ORDER:1","ABE4",true); + fixture.item(302,30,"B","ORDER:2","ABE4",true); + fixture.item(401,40,"B","ORDER:1","ABE4",true); + fixture.item(501,50,"A","ORDER:1","ONT8",true); + var refs=List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), + new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), + new RequirementSource(1,"B","ORDER:2","FBA:ABE4"), + new RequirementSource(2,"B","ORDER:1","FBA:ABE4")); + var history=fixture.repository.history(refs); + assertThat(history).filteredOn(row->row.groupOrdinal()==0).extracting(row->row.appointmentId()).containsExactly(30L,20L,10L); + assertThat(history).filteredOn(row->row.groupOrdinal()==1).extracting(row->row.appointmentId()).containsExactly(30L); + assertThat(history).filteredOn(row->row.groupOrdinal()==2).extracting(row->row.appointmentId()).containsExactly(40L); + assertThat(fixture.repository.items(List.of(10L,20L,30L))).hasSize(4) + .filteredOn(row->!Boolean.TRUE.equals(row.getActive())).extracting(InboundFbaAppointmentItem::getId).containsExactly(101L,201L); + assertThat(fixture.repository.appointments(List.of(20L))).singleElement().satisfies(row->{ + assertThat(row.getReplacementNosJson()).isEqualTo("[\"AP-30\"]"); + assertThat(row.getReplacementKeysJson()).isEqualTo("[\"FBA_APPOINTMENT:30\"]"); + }); + var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( + List.of(new SourceRef("A","ORDER:1","FBA:ABE4")),List.of(new SourceRef("B","ORDER:2","FBA:ABE4")))); + assertThat(prepared.apply(0)).extracting(row->row.id()).containsExactly(10L,20L,30L); + assertThat(prepared.apply(0)).filteredOn(row->row.id()==10).singleElement().satisfies(row->{ + assertThat(row.appointmentStatus()).isEqualTo("CANCELLED"); + assertThat(row.items()).isEmpty(); + }); + assertThat(prepared.apply(0)).filteredOn(row->row.id()==20).singleElement().satisfies(row->{ + assertThat(row.items()).hasSize(1); + assertThat(row.replacementBatchKeys()).containsExactly("FBA_APPOINTMENT:30"); + }); + assertThat(prepared.apply(1)).singleElement().satisfies(row->{ + assertThat(row.id()).isEqualTo(30L); + assertThat(row.items()).extracting(item->item.customerId()).containsExactly("A","B"); + }); + } + } + + /** SQL 对每组各保留第1001条哨兵,不能以所有组总数截断历史。 */ + @Test + void windowLimitIsPerRequirementGroupAndDuplicateReferencesDoNotUseUpTheLimit() throws Exception { + try (var fixture=new Fixture()) { + for(int i=1;i<=1002;i++)fixture.item(i,i,"A","ORDER:1","ABE4",i%2==0); + for(int i=1;i<=1000;i++)fixture.item(10000+i,20000+i,"B","ORDER:2","ABE4",true); + var rows=fixture.repository.history(List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), + new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),new RequirementSource(1,"B","ORDER:2","FBA:ABE4"))); + assertThat(rows).hasSize(2001); + assertThat(rows).filteredOn(row->row.groupOrdinal()==0).hasSize(1001) + .extracting(row->row.appointmentId()).startsWith(1002L).endsWith(2L).doesNotContain(1L); + assertThat(rows).filteredOn(row->row.groupOrdinal()==1).hasSize(1000); + } + } + + /** 空输入直接结束,不向数据库提交无效IN列表。 */ + @Test + void emptyInputsReturnNoRows() throws Exception { + try (var fixture=new Fixture()) { + assertThat(fixture.repository.history(List.of())).isEmpty(); + assertThat(fixture.repository.appointments(List.of())).isEmpty(); + assertThat(fixture.repository.items(List.of())).isEmpty(); + assertThat(fixture.repository.sources(List.of())).isEmpty(); + } + } + + /** 100个来源和200个预约的分块不会退化成逐组SQL,预备结果重读不再访问数据库。 */ + @Test + void manyGroupsUseBoundedRealQueriesAndPreparedResponsesDoNotReadAgain() throws Exception { + try(var fixture=new Fixture()) { + var groups=new ArrayList>(); + for(int i=1;i<=201;i++) { + fixture.appointment(i,"PENDING",null,null); + fixture.item(i,i,"A","ORDER:"+i,"ABE4",true); + groups.add(List.of(new SourceRef("A","ORDER:"+i,"FBA:ABE4"))); + } + var prepared=fixture.service.prepareRelatedBatchSnapshots(groups); + for(int i=0;i<201;i++)assertThat(prepared.apply(i)).extracting(row->row.id()).containsExactly((long)i+1); + assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(3); + assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(2); + assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(2); + int before=fixture.counter.statements.size(); + assertThat(prepared.apply(0)).hasSize(1); + assertThat(fixture.counter.statements).hasSize(before); + } + } + + /** 同一需求的来源跨SQL分块时,相同预约只归属一次,空来源组仍独立。 */ + @Test + void oneGroupCrossingTheSourceChunkBoundaryDeduplicatesItsOriginalAppointment() throws Exception { + try(var fixture=new Fixture()) { + fixture.appointment(30,"PENDING",null,null); + fixture.item(301,30,"A","ORDER:1","ABE4",true); + fixture.item(302,30,"A","ORDER:2","ABE4",true); + var first=new ArrayList(); + for(int i=0;i<99;i++)first.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); + var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(first,List.of( + new SourceRef("A","ORDER:1","FBA:ABE4"),new SourceRef("A","ORDER:2","FBA:ABE4")),List.of())); + assertThat(prepared.apply(0)).isEmpty(); + assertThat(prepared.apply(1)).singleElement().satisfies(row->{ + assertThat(row.id()).isEqualTo(30L);assertThat(row.items()).hasSize(2); + }); + assertThat(prepared.apply(2)).isEmpty(); + assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); + assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(1); + } + } + + /** 每组1000条允许,1001条和101个原来源仅在访问本组时拒绝,不拖垮其他组。 */ + @Test + void groupLimitsAreCheckedAfterCompleteMergeAndErrorsStayWithTheirOriginalGroup() throws Exception { + try(var fixture=new Fixture()) { + for(int i=1;i<=1000;i++) { + fixture.appointment(i,"PENDING",null,null); + fixture.item(i,i,"A","ORDER:1","ABE4",true); + } + for(int i=1;i<=1001;i++)fixture.item(10000+i,20000+i,"B",i<=600?"ORDER:2":"ORDER:3","ABE4",false); + var validGroup=new ArrayList(); + validGroup.add(new SourceRef("A","ORDER:1","FBA:ABE4")); + for(int i=0;i<98;i++)validGroup.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); + // 超限组的600条与401条来自两个SQL块,必须合并后才能准确拒绝。 + var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( + validGroup, + List.of(new SourceRef("B","ORDER:2","FBA:ABE4"),new SourceRef("B","ORDER:3","FBA:ABE4")), + Collections.nCopies(101,new SourceRef("A","ORDER:1","FBA:ABE4")))); + assertThat(prepared.apply(0)).hasSize(1000); + assertThatThrownBy(()->prepared.apply(1)).isInstanceOfSatisfying(ResponseStatusException.class, + error->assertThat(error.getStatusCode().value()).isEqualTo(503)); + assertThatThrownBy(()->prepared.apply(2)).isInstanceOfSatisfying(ResponseStatusException.class, + error->assertThat(error.getStatusCode().value()).isEqualTo(400)); + assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(5); + assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(5); + assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); + } + } + + @Intercepts(@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})) + private static final class QueryCounter implements Interceptor { + final List statements=new ArrayList<>(); + /** 记录真实Mapper执行入口,调用仍继续进入原SQL执行器。 */ + @Override public Object intercept(Invocation invocation) throws Throwable { + statements.add(((MappedStatement)invocation.getArgs()[0]).getId());return invocation.proceed(); + } + /** 按具体Mapper语句核对批量读取次数。 */ + long count(String suffix) { return statements.stream().filter(id->id.endsWith(suffix)).count(); } + } + + private static final class Fixture implements AutoCloseable { + final JdbcTemplate jdbc; + final SqlSession session; + final AppointmentRequirementReadRepository repository; + final InboundFbaAppointmentService service; + final QueryCounter counter=new QueryCounter(); + + /** 只在独立H2内使用基线表,并装配真实生产Mapper。 */ + Fixture() throws Exception { + var datasource=new JdbcDataSource(); + datasource.setURL("jdbc:h2:mem:requirement-history-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc=new JdbcTemplate(datasource); + String baseline; + try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8); + } + for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" ("); + assertThat(start).isGreaterThanOrEqualTo(0); + jdbc.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";")); + } + var config=new MybatisConfiguration(); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),datasource)); + config.setMapUnderscoreToCamelCase(true); + config.addInterceptor(counter); + config.addMapper(AppointmentWorkSourceMapper.class); + config.addMapper(InboundFbaAppointmentQueryMapper.class); + config.addMapper(InboundFbaAppointmentMapper.class); + config.addMapper(InboundFbaAppointmentItemMapper.class); + session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(); + repository=new AppointmentRequirementReadRepository(session.getMapper(AppointmentWorkSourceMapper.class), + session.getMapper(InboundFbaAppointmentQueryMapper.class),session.getMapper(InboundFbaAppointmentMapper.class), + session.getMapper(InboundFbaAppointmentItemMapper.class)); + service=new InboundFbaAppointmentService(); + ReflectionTestUtils.setField(service,"requirementReads",repository); + ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); + } + + /** 建立真实预约头,取消和替代分别保留原状态与去向。 */ + void appointment(long id,String status,String replacementNos,String replacementKeys) { + jdbc.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by,appointment_status,replacement_nos_json,replacement_keys_json) VALUES(?,?,'WH','ABE4','TEST',?,?,?)", + id,"AP-"+id,status,replacementNos,replacementKeys); + } + + /** 建立原来源历史,不以活动标记代替来源归属。 */ + void item(long id,long appointment,String customer,String source,String destination,boolean active) { + jdbc.update("INSERT INTO ll_inbound_fba_appointment_item(id,appointment_id,customer_id,source_key,fba_warehouse_code,active,package_count) VALUES(?,?,?,?,?,?,1)", + id,appointment,customer,source,destination,active?1:0); + } + + /** 测试结束关闭本地会话,不接触任何共享数据库。 */ + @Override public void close() { session.close(); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index 3d5c93c7b..a0eb47036 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -11,11 +11,32 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; class AppointmentWorkRequirementProviderTest { + /** 一组前置未就绪只保留自己的原义务,同页其他客户的需求与独立读取一致。 */ + @Test void pagePreparationKeepsUnresolvedGroupsLocalAndMatchesIndependentReads() { + var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); + var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of(new AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",10));}); + when(sources.canonicalOrders(anyList())).thenAnswer(c->c.>getArgument(0).stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList()); + when(sources.resolveBatch(anyList())).thenAnswer(c->{ + var ref=c.>getArgument(0).get(0); + if(ref.sourceKey().equals("ORDER:1")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"货物尚无已开船或后续实际船节点"); + return List.of(new AppointmentSourceService.Source(ref.sourceKey(),ref.customerId(),2L,"IN2",null,"CNT2","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())); + }); + var available=mock(AppointmentCargoVerificationService.Context.class);when(available.availablePackages()).thenReturn(10L);when(cargo.inspect(any(AppointmentSourceService.Source.class))).thenReturn(available); + var containers=List.of("1","2").stream().map(id->new ContainerSource(id,"CNT"+id,null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:"+id,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); + var page=provider.openRequirements(containers.stream().map(c->new RequirementInput(c,Flow.TRANSFER)).toList()); + var first=page.read(containers.get(0),Flow.TRANSFER);var second=page.read(containers.get(1),Flow.TRANSFER); + assertEquals(List.of(new SourceReference("C1","ORDER:1",null)),first.get(0).sources()); + assertEquals(List.of(new SourceReference("C2","ORDER:2","FBA:ONT8")),second.get(0).sources()); + assertEquals(first,provider.openRequirements(List.of()).read(containers.get(0),Flow.TRANSFER)); + assertEquals(second,provider.openRequirements(List.of()).read(containers.get(1),Flow.TRANSFER)); + } /** 取消历史没有活动货物时退役旧事项,仍按权威分配保留新批次和未分配余量。 */ @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -33,29 +54,29 @@ class AppointmentWorkRequirementProviderTest { when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2"); when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current)); - var actual=provider.openRequirements().read(container,Flow.TRANSFER); + var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); when(allocation.availablePackages()).thenReturn(20L); - actual=provider.openRequirements().read(container,Flow.TRANSFER); + actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled)); when(allocation.availablePackages()).thenReturn(60L); - actual=provider.openRequirements().read(container,Flow.TRANSFER); + actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); // 非取消的空批次仍属缺失事实,不能静默跳过。 when(cancelled.appointmentStatus()).thenReturn("PENDING"); - assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value()); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)).getStatusCode().value()); } @Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -72,73 +93,87 @@ class AppointmentWorkRequirementProviderTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); for(var status:List.of("CANCELLED","FAILED")) { when(batch.appointmentStatus()).thenReturn(status); - var result=provider.openRequirements().read(container,Flow.TRANSFER); + var result=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources()); assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1")); } when(allocation.availablePackages()).thenReturn(20L); - assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2")); - assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); } @Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",4))); var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null)); when(sources.canonicalOrders(anyList())).thenReturn(refs); when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")); - var actual=provider.openRequirements().read(container,Flow.TRANSFER); + var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey()); assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources()); verifyNoInteractions(cargo,appointments); doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)); } @Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10))); var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4); when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch)); - var manifest=provider.openRequirements().read(container,Flow.TRANSFER); + var manifest=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList()); assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey()); when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4))); - var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + var restored=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); verifyNoInteractions(sources,cargo,appointments); } @Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() { var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement)); - assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT)); + assertEquals(List.of(requirement),provider.openRequirements(java.util.List.of()).read(source,Flow.DIRECT)); verifyNoInteractions(sources,cargo,appointments,mapping); } /** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */ @Test void missingLastlegSourcesDoNotOpenDirectoryScope() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); - var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping); + var reader=provider.openRequirements(java.util.List.of());verifyNoInteractions(sources,cargo,appointments,mapping); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments); } + /** 旧业务场景继续模拟相同原事实,查询形状由真实批量集成回归单独验证。 */ + private static AppointmentWorkRequirementProvider provider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper mapping) { + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var reads=mock(AppointmentRequirementReadRepository.class); + when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); + org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); + when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); + when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); + return provider; + } /** 保留原来源模拟,显式接入本次读取作用域。 */ private static void scoped(AppointmentSourceService sources) { when(sources.openReadScope()).thenAnswer(call->{ var scope=mock(AppointmentSourceService.ReadScope.class); - when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); + when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{ + var refs=sources.canonicalOrders(group); + try { return new AppointmentSourceService.OrderGroup(refs,sources.resolveBatch(refs),null); } + catch(RuntimeException error) { return new AppointmentSourceService.OrderGroup(refs,List.of(),error); } + }).toList()); return scope; }); }