From 2971d6f7f0baedbf00dbb7941b1d846a736224a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 09:57:54 +0800 Subject: [PATCH] perf(lastleg): reuse warehouse directory within sync generation --- .../AppointmentSourceService.java | 28 ++++++++-- .../AppointmentWorkFactAdapter.java | 22 +++++--- .../AppointmentWorkFactGateway.java | 8 +-- .../AppointmentWorkSyncService.java | 13 ++--- .../AppointmentWorkRequirementProvider.java | 18 ++++++- .../AppointmentSourceServiceTest.java | 32 +++++++++++ .../AppointmentWorkFactAdapterTest.java | 27 ++++++++++ .../AppointmentWorkPersistenceTest.java | 2 +- .../AppointmentWorkSyncServiceTest.java | 49 ++++++++++++++++- .../appointmentwork/WorkTestFixture.java | 2 +- ...ppointmentWorkRequirementProviderTest.java | 53 +++++++++++++------ 11 files changed, 212 insertions(+), 42 deletions(-) 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 62757c22c..d2957eb37 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 @@ -58,18 +58,36 @@ public class AppointmentSourceService { return response.data().warehouse().timezone(); } catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); } } - /** One catalog request and batched source reads, including mixed first-leg and external bookings. */ + /** 普通批量读取创建独立目录作用域,同时读取头程和外接登记来源。 */ public List resolveBatch(List requested) { - return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList(); + return openReadScope().resolveBatch(requested); + } + /** 创建显式单次读取作用域;仅成功的目录结果可在本次生成中复用。 */ + public ReadScope openReadScope() { return new ReadScope(); } + public final class ReadScope { + private List directory; + /** 只能由所属来源服务创建,不允许传入未经校验的目录。 */ + private ReadScope() { } + /** 每次仍读取原订单和权限事实,仅目录共享;不保存来源解析结果。 */ + public List resolveBatch(List requested) { + return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList(); + } + /** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */ + private List catalog() { + if(directory==null) directory=AppointmentSourceService.this.catalog(); + return directory; + } } /** 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; } } + /** 每次检查独立读取目录,保留逐来源前置条件失败与基础设施异常边界。 */ public List inspectBatch(List requested) { - return resolveBatch(requested,false); + return resolveBatch(requested,false,openReadScope()); } - private List resolveBatch(List requested,boolean strict) { + /** 保留原批量校验与错误边界,目录只从调用方的局部作用域读取。 */ + private List resolveBatch(List requested,boolean strict,ReadScope scope) { if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源"); var keys=new LinkedHashSet(); var orderIds=new LinkedHashSet(); var bookingRefs=new java.util.ArrayList(); @@ -96,7 +114,7 @@ public class AppointmentSourceService { var destinations=transferRefs.isEmpty()?Map.of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity())); var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); var gates=containerIds.isEmpty()?Map.of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40)); - var catalog=catalog(); + var catalog=scope.catalog(); var result=requested.stream().map(ref->{ try { if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey())))) 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 3f9e71762..0fe8a759e 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 @@ -68,12 +68,22 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { }); return result; } - public List requirements(ContainerSource source,Flow effectiveFlow) { - var provider=requirements.getIfAvailable(); - if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); - var result=provider.requirements(source,effectiveFlow); - if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); - return List.copyOf(result); + /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ + public RequirementReader openRequirements() { + return new RequirementReader() { + private RequirementReader reader; + /** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */ + public List read(ContainerSource source,Flow effectiveFlow) { + if(reader==null) { + var provider=requirements.getIfAvailable(); + if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); + reader=provider.openRequirements(); + } + var result=reader.read(source,effectiveFlow); + if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); + return List.copyOf(result); + } + }; } static void append(Map target,List requests,List facts) { if(facts==null || facts.size()!=requests.size()) 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 e37882ed9..e2af776af 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 @@ -27,8 +27,8 @@ public interface AppointmentWorkFactGateway { } SourcePage discover(String after, int size); ContainerSource container(String containerBookingId); - /** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */ - List requirements(ContainerSource source,Flow effectiveFlow); + /** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */ + RequirementReader openRequirements(); Map facts(List states); Owner owner(String id, Actor actor); Responsibility supplier(String id); @@ -41,5 +41,7 @@ public interface AppointmentWorkFactGateway { /** IAM-backed active platform operators; display names supplied by callers are never trusted. */ interface OwnerDirectory { Owner activePlatformOwner(String id); } interface SupplierDirectory { Responsibility activeSupplier(String id); } - interface RequirementProvider { List requirements(ContainerSource source,Flow effectiveFlow); } + /** 单次生成显式复用目录,不跨请求保存来源事实。 */ + interface RequirementReader { List read(ContainerSource source,Flow effectiveFlow); } + interface RequirementProvider { RequirementReader openRequirements(); } } 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 5f4e039b5..0f0e091bd 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,6 +36,7 @@ public class AppointmentWorkSyncService { timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); + var requirementReader=gateway.openRequirements(); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); @@ -45,13 +46,13 @@ public class AppointmentWorkSyncService { if(existing.isEmpty()) { if(source.establishedFlow()==Flow.UNKNOWN) { var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++; - } else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known).size(); + } else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader).size(); } else { var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null); - if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known).size(); + if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known,requirementReader).size(); else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) { if(source.establishedFlow()!=Flow.UNKNOWN) { - var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known); created+=generated.size(); + var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader); created+=generated.size(); route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route); } else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) { route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route); @@ -125,7 +126,7 @@ public class AppointmentWorkSyncService { } /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { - return generate(source,flow,existing,actor,workingSet()); + return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements()); } /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ private Map workingSet() { @@ -135,10 +136,10 @@ public class AppointmentWorkSyncService { } /** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */ private List generate(ContainerSource source,Flow flow,List existing,Actor actor, - Map known) { + Map known,RequirementReader requirementReader) { validateSource(source); if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确"); - var requirements=gateway.requirements(source,flow); + var requirements=requirementReader.read(source,flow); source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements); validateSource(source); if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单"); 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 1bae1c9f4..db8e65d91 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 @@ -20,8 +20,22 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa 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() { + return new RequirementReader() { + private AppointmentSourceService.ReadScope scope; + /** 直送和未建立尾程来源的需求不创建目录读取依赖。 */ + public List read(ContainerSource container,Flow flow) { + return requirements(container,flow,refs->{ + if(scope==null) scope=sources.openReadScope(); + return scope.resolveBatch(refs); + }); + } + }; + } /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ - public List requirements(ContainerSource container,Flow effectiveFlow) { + 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(); if(effectiveFlow==Flow.DIRECT) return original; if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); @@ -38,7 +52,7 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa 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()); List resolved; - try { resolved=sources.resolveBatch(refs); } + try { resolved=resolver.apply(refs); } 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 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 0543d3efc..3082da1b6 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,38 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentSourceServiceTest { + /** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */ + @Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() { + 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"); + var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOrderType("FIRST_LEG"); + order.setSourceContainerBookingId("C1");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10); + when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order)); + var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40); + when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate)); + var catalog=ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))); + var outage=new IllegalStateException("offline"); + when(warehouse.catalog("token")).thenThrow(outage).thenReturn(catalog); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")); + var scope=service.openReadScope();verifyNoInteractions(warehouse); + var error=assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));assertEquals(503,error.getStatusCode().value());assertSame(outage,error.getCause()); + assertEquals(10,scope.resolveBatch(refs).get(0).packages());verify(warehouse,times(2)).catalog("token"); + order.setPackageCount(12);assertEquals(12,scope.resolveBatch(refs).get(0).packages());verify(warehouse,times(2)).catalog("token"); + assertEquals(404,assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8")))).getStatusCode().value()); + assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9")))); + assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(refs.get(0),refs.get(0)))); + gate.setNodeSequence(30);assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));gate.setNodeSequence(40); + order.setOverseasWarehouseId("missing");assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));order.setOverseasWarehouseId("100"); + when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,12,1L))); + assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));when(external.resolveOrders(anyList())).thenReturn(List.of()); + assertEquals(12,service.openReadScope().resolveBatch(refs).get(0).packages());verify(warehouse,times(3)).catalog("token"); + service.resolveBatch(refs);service.resolveBatch(refs);service.inspectBatch(refs);service.inspectBatch(refs);verify(warehouse,times(7)).catalog("token"); + when(warehouse.catalog("token")).thenReturn(null).thenReturn(catalog); + var next=service.openReadScope();assertEquals(503,assertThrows(ResponseStatusException.class,()->next.resolveBatch(refs)).getStatusCode().value()); + assertEquals(12,next.resolveBatch(refs).get(0).packages());verify(warehouse,times(9)).catalog("token"); + } + @Test void batchResolvesTwoCustomersWithOneCatalogAndOneOrderAndShippingQuery() { var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.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 5872e4ec5..bb2b62a64 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 @@ -9,6 +9,33 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; class AppointmentWorkFactAdapterTest { + /** 未读取需求的未知路线不依赖提供端,首次读取才开作用域并逐份校验结果。 */ + @SuppressWarnings("unchecked") + @Test void requirementReaderIsLazyAndKeepsProviderCompletenessChecks() { + var providers=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); + 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 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.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); + assertThrows(ResponseStatusException.class,()->unused.read(source,Flow.TRANSFER)); + 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); + 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(); + 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(); + } @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of()); 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 962bf0032..af142038b 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.requirements(source,Flow.TRANSFER)).thenReturn(List.of(requirement)); + org.mockito.Mockito.when(facts.openRequirements()).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 b590682eb..2e5d49be2 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,6 +12,50 @@ 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); + var shipping=mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class); + var external=mock(com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.class); + var warehouse=mock(com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.class); + when(warehouse.catalog("token")).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of( + new com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"), + new com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + when(orders.selectBatchIds(anyCollection())).thenAnswer(c->c.>getArgument(0).stream().map(id->{ + var order=new com.baiyue.lastleg.entity.LastlegOrder();order.setId(id);order.setCustomerId("C"+id); + order.setOrderType("FIRST_LEG");order.setSourceContainerBookingId(id.toString());order.setOverseasWarehouseId("100"); + order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(1);return order; + }).toList()); + when(shipping.selectBatchIds(anyCollection())).thenAnswer(c->c.>getArgument(0).stream().map(id->{ + 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"); + 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); + var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); + when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of( + 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()); + 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()); + 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()); + f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); + 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()); + } + /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ @Test void hundredContainersReuseOneGenerationWorkingSetAndKeepUnknownRoutesAndHistory() { var f=new WorkTestFixture(); @@ -57,6 +101,9 @@ class AppointmentWorkSyncServiceTest { var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); var appointments=mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class); 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))); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,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()); @@ -70,7 +117,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)); - doAnswer(c->provider.requirements(c.getArgument(0),c.getArgument(1))).when(f.gateway).requirements(any(),eq(Flow.TRANSFER)); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); 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)); 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 1c9ec32f0..bc953d55a 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.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements()); + when(gateway.openRequirements()).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/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index 1ff8f4d24..3d5c93c7b 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 @@ -15,7 +15,7 @@ class AppointmentWorkRequirementProviderTest { @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { 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); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(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 +33,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.requirements(container,Flow.TRANSFER); + var actual=provider.openRequirements().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.requirements(container,Flow.TRANSFER); + actual=provider.openRequirements().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.requirements(container,Flow.TRANSFER); + actual=provider.openRequirements().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.requirements(container,Flow.TRANSFER)).getStatusCode().value()); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().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); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(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,55 +72,74 @@ 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.requirements(container,Flow.TRANSFER); + var result=provider.openRequirements().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.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2")); - assertEquals(List.of("INBOUND:1"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1"),provider.openRequirements().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); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(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.requirements(container,Flow.TRANSFER); + var actual=provider.openRequirements().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.requirements(container,Flow.TRANSFER)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().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); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(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.requirements(container,Flow.TRANSFER); + var manifest=provider.openRequirements().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.requirements(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + var restored=provider.openRequirements().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); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(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.requirements(source,Flow.DIRECT)); + assertEquals(List.of(requirement),provider.openRequirements().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 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); + assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments); + } + + /** 保留原来源模拟,显式接入本次读取作用域。 */ + 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))); + return scope; + }); + } }