chore(lastleg): instrument appointment latency from restored 662dead baseline

This commit is contained in:
2026-10-18 11:43:10 +08:00
parent b0acbfbb34
commit 5cd108b39a
35 changed files with 951 additions and 780 deletions
@@ -11,22 +11,6 @@ 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.<List<AppointmentCoverageService.SourceRef>>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());
@@ -146,10 +146,6 @@ 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); }
@@ -12,56 +12,6 @@ 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.<java.util.Collection<Long>>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);
@@ -10,6 +10,21 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkFactAdapterTest {
/** 原事实分批沿请求作用域关联,provider计数和返回顺序保持不变。 */
@Test void diagnosticScopeKeepsTraceAndOriginalProviderBatchCount() {
var f=new DiagnosticFixture();var states=new ArrayList<AppointmentWorkState>();
for(int i=0;i<201;i++)states.add(f.work.state("private-receiving-"+i,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null));
org.slf4j.MDC.put("requestId","safe-request");org.slf4j.MDC.put("traceId","safe-trace");
try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open(
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP,
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)){
var result=f.adapter.facts(states);assertEquals(201,result.size());assertEquals(List.of(200,1),f.providerSizes);
assertEquals(2,scope.snapshot().metrics().get("FACTS_RECEIVING").calls());
assertEquals(2,scope.snapshot().metrics().get("FACTS_VALIDATE").calls());
assertEquals("safe-trace",scope.snapshot().traceId());assertEquals("safe-request",scope.snapshot().requestId());
assertFalse(scope.snapshot().toString().contains("private-"));verifyNoInteractions(f.firstleg);
}finally{org.slf4j.MDC.clear();}
}
/** 慢成功仅追加聚合诊断,原二百项批量、调用次序和事实结果保持一致。 */
@Test void slowSuccessSummarizesOriginalBatchesWithoutBusinessContent() {
var f=new DiagnosticFixture();
@@ -103,10 +118,10 @@ class AppointmentWorkFactAdapterTest {
var business=(org.springframework.beans.factory.ObjectProvider<BusinessProvider>)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(java.util.List.of());org.mockito.Mockito.verifyNoInteractions(providers);
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(java.util.List.of())).thenAnswer(c->adapter.openRequirements(java.util.List.of()));
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);
@@ -114,14 +129,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(java.util.List.of())).thenReturn(delegate);
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(java.util.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(java.util.List.of());
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);
@@ -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(org.mockito.ArgumentMatchers.anyList())).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->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");
@@ -12,6 +12,35 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkQueryServiceTest {
/** 开启计时不改变FOLLOW_TODAY分页、汇总与日历,最终后处理各自有独立桶。 */
@Test void diagnosticScopePreservesRealListSummaryAndCalendarResults(){
var f=new WorkTestFixture();var state=f.state("private-source",Type.PICKUP,Flow.TRANSFER,null);state.setFollowupDate(LocalDate.of(2026,10,14));f.add(state);
var expectedItems=f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR);
var expectedSummary=f.queries.summary(ACTOR);var from=LocalDate.of(2026,10,1);var to=LocalDate.of(2026,10,31);
var expectedCalendar=f.queries.calendar(from,to,null,null,ACTOR);
try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open(
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP,
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.ITEMS)){
assertEquals(expectedItems,f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR));
assertEquals(expectedSummary,f.queries.summary(ACTOR));assertEquals(expectedCalendar,f.queries.calendar(from,to,null,null,ACTOR));
assertEquals(3,scope.snapshot().metrics().get("QUERY_FINAL").calls());
}
}
/** 详情阶段计时不得增加事实、完整工作集或历史读取,也不改变返回值。 */
@Test void diagnosticScopeMeasuresOriginalDetailStagesWithoutAdditionalReads() {
var f=new WorkTestFixture();var state=f.state("private-container",Type.PICKUP,Flow.TRANSFER,null);f.add(state);
var expected=f.queries.detail(state.getWorkItemKey(),ACTOR);clearInvocations(f.gateway,f.mapper);
try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open(
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP,
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)) {
assertEquals(expected,f.queries.detail(state.getWorkItemKey(),ACTOR));
var metrics=scope.snapshot().metrics();
for(var stage:List.of("QUERY_GUARD","QUERY_DISCOVER","QUERY_STORE","QUERY_SELECT","QUERY_FACTS","QUERY_PROJECT","QUERY_NEXT","DETAIL_STORE","DETAIL_HISTORY"))assertEquals(1,metrics.get(stage).calls(),stage);
assertFalse(scope.snapshot().toString().contains("private-container"));
verify(f.gateway).facts(anyList());verify(f.gateway).discover(null,1);verify(f.mapper).all(anyInt());verify(f.mapper).selectHistory(state.getWorkItemKey());
}
assertFalse(com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.active());
}
@Test void sameBatchRequiresExplicitCustomerAndNeverChoosesTheFirstCandidate() {
var f=new WorkTestFixture();var expected=new HashMap<String,String>();
for(var customer:List.of("A","B")) {
@@ -12,7 +12,20 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkSyncServiceTest {
/** 百柜同页批量读取来源原料,下页与独立命令重读,生成状态保持不变。 */
/** 启用诊断不增加同步回读或事实批次,原完整发布结果保持一致。 */
@Test void diagnosticScopePreservesOriginalSyncReadCountsAndResult(){
var f=new WorkTestFixture();when(f.gateway.discover(null,100)).thenReturn(new SourcePage(List.of(),null,true,"scope"));
var expected=f.sync.synchronize(new SyncRequest(null,100));clearInvocations(f.gateway,f.mapper);
try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open(
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.SYNC,
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.INTERNAL)){
assertEquals(expected,f.sync.synchronize(new SyncRequest(null,100)));
var metrics=scope.snapshot().metrics();
for(var stage:List.of("SYNC_BODY","SYNC_GUARD","SYNC_DISCOVER","SYNC_GENERATE","SYNC_TRANSPORT_FACTS","SYNC_RECONCILE","SYNC_ACTIVE_STORE","SYNC_ACTIVE_FACTS","SYNC_CHECKPOINT"))assertEquals(1,metrics.get(stage).calls(),stage);
verify(f.gateway,times(2)).facts(anyList());verify(f.mapper,times(3)).all(anyInt());verify(f.mapper).lockGuard();verify(f.mapper).saveGuard(any());
}
}
/** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */
@Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() {
var f=new WorkTestFixture();
var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);
@@ -31,7 +44,6 @@ 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);
@@ -40,18 +52,13 @@ 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);
wireBatches(provider,cargo,mapping);
when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0)));
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(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));
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());
@@ -59,27 +66,7 @@ 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(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.<List<RequirementInput>>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());
}
verify(orders,times(202)).selectBatchIds(anyCollection());
}
/** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */
@@ -129,9 +116,8 @@ 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.resolveOrderGroups(anyList())).thenAnswer(c->c.<List<List<com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.OrderRef>>>getArgument(0).stream().map(group->{var r=sources.canonicalOrders(group);return new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.OrderGroup(r,sources.resolveBatch(r),null);}).toList());
when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0)));
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());
@@ -144,7 +130,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(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0)));
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));
@@ -400,13 +386,4 @@ 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.<List<String>>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.<List<List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef>>>getArgument(0);return (java.util.function.IntFunction<List<com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse>>)(i->appointments.relatedBatchSnapshots(groups.get(i)));});
}
}
@@ -38,7 +38,7 @@ final class WorkTestFixture {
when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap<String,Fact>(); for(var state:c.<List<AppointmentWorkState>>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(anyList())).thenAnswer(c->(RequirementReader)(source,flow)->source.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);
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.*;
import jakarta.servlet.http.HttpServletResponse;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
class AppointmentWorkDiagnosticFilterTest {
/** 路由模板不保存路径参数或querystring,其他业务入口不建立scope。 */
@Test void fixedRoutesDoNotRetainBusinessIdsAndUnrelatedEndpointsPassThrough()throws Exception{
var filter=new AppointmentWorkDiagnosticFilter();
var request=new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/items/private-id");request.setQueryString("token=private-secret");
filter.doFilter(request,new MockHttpServletResponse(),(r,s)->{
assertEquals(Route.DETAIL,AppointmentWorkDiagnostics.currentSnapshot().route());
assertFalse(AppointmentWorkDiagnostics.currentSnapshot().toString().contains("private-"));
});assertFalse(active());
filter.doFilter(new MockHttpServletRequest("GET","/other"),new MockHttpServletResponse(),(r,s)->assertFalse(active()));
}
/** 原filter异常原样透传,随后线程作用域清理。 */
@Test void originalFailureIsNotReplacedAndScopeAlwaysCloses(){
var error=new IllegalStateException("private-secret");var filter=new AppointmentWorkDiagnosticFilter();
assertSame(error,assertThrows(IllegalStateException.class,()->filter.doFilter(new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/items"),new MockHttpServletResponse(),(r,s)->{throw error;})));
assertFalse(active());
}
/** 响应状态的诊断读取出错也不能阻止finally清理或覆盖原异常。 */
@Test void brokenResponseStatusObservationCannotMaskBusinessFailure(){
var response=mock(HttpServletResponse.class);when(response.getStatus()).thenThrow(new AssertionError("response failed"));
var error=new IllegalStateException("original");var filter=new AppointmentWorkDiagnosticFilter();
assertSame(error,assertThrows(IllegalStateException.class,()->filter.doFilter(new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/summary"),response,(r,s)->{throw error;})));
assertFalse(active());
}
}
@@ -0,0 +1,287 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Actor;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.SyncRequest;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.SyncResult;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.WorkDetail;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkQueryService;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkStore;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkSyncService;
import com.zaxxer.hikari.HikariDataSource;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.AopTestUtils;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.DefaultTransactionStatus;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionSynchronization;
import javax.sql.DataSource;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.Mockito.mock;
/** 使用真实Spring注册、Hikari及内存H2验证诊断不改变原事务与连接生命周期。 */
class AppointmentWorkDiagnosticsInfrastructureTest {
/** 每个场景都必须退出当前线程的诊断作用域,不能污染后续测试或请求。 */
@AfterEach
void noScopeLeaksFromTheTestThread() {
assertThat(AppointmentWorkDiagnostics.active()).isFalse();
assertThat(AppointmentWorkDiagnostics.currentScopeId()).isNull();
}
/** 解包保持容器Bean自身语义和配置委托;内部target身份不同,但池与关闭行为不变。 */
@Test
void hikariProxyRemainsInjectableAndTransactionAndMybatisUseTheSameBean() throws Exception {
HikariDataSource original;
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var dataSource=context.getBean(HikariDataSource.class);
original=AopTestUtils.getUltimateTargetObject(dataSource);
assertThat(AopUtils.isCglibProxy(dataSource)).isTrue();
assertThat(dataSource).isNotSameAs(original);
assertThat(original.unwrap(HikariDataSource.class)).isSameAs(original);
assertThat(dataSource.unwrap(HikariDataSource.class)).isSameAs(dataSource);
assertThat(dataSource.getJdbcUrl()).isEqualTo(original.getJdbcUrl());
assertThat(dataSource.getUsername()).isEqualTo(original.getUsername());
assertThat(dataSource.getPassword()).isEqualTo(original.getPassword());
assertThat(dataSource.getDataSourceProperties()).isSameAs(original.getDataSourceProperties());
assertThat(dataSource.isWrapperFor(HikariDataSource.class)).isTrue();
assertThat(context.getBean(ObservingTransactionManager.class).getDataSource()).isSameAs(dataSource);
assertThat(context.getBean(SqlSessionFactory.class).getConfiguration().getEnvironment().getDataSource()).isSameAs(dataSource);
try(Connection connection=dataSource.getConnection()) {
assertThat(connection.getClass().getName()).startsWith("com.zaxxer.hikari.pool.HikariProxyConnection");
assertThat(dataSource.getHikariPoolMXBean()).isSameAs(original.getHikariPoolMXBean());
assertThat(original.getHikariPoolMXBean().getActiveConnections()).isEqualTo(1);
}
assertThat(original.getHikariPoolMXBean().getActiveConnections()).isZero();
assertThat(original.isClosed()).isFalse();
}
assertThat(original.isClosed()).isTrue();
}
/** 自动注册的诊断Advisor必须包住事务提交,Mapper连续调用共用同一事务连接。 */
@Test
void productionAdvisorStaysActiveThroughCommitAndPreservesTheCommittedWrite() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var probe=context.getBean(Probe.class);
var service=context.getBean(QueryProbe.class);
assertThat(AopUtils.isAopProxy(service)).isTrue();
service.detail("commit",null);
assertThat(probe.bodyScope).isNotNull();
assertThat(probe.events).containsExactly("body","commit-before","commit-after");
assertThat(probe.transactionScopes).containsOnly(probe.bodyScope);
assertThat(probe.transactionActive).isTrue();
assertThat(probe.boundResource).isTrue();
assertThat(probe.firstSession).isEqualTo(probe.secondSession);
assertThat(probe.bodySnapshot.metrics().get("CONNECTION_ACQUIRE").calls()).isEqualTo(1);
assertThat(probe.bodySnapshot.metrics().get("DB_EXECUTOR_UPDATE:OTHER").calls()).isEqualTo(1);
assertThat(context.getBean(ProbeMapper.class).count()).isEqualTo(1);
assertThat(AppointmentWorkDiagnostics.active()).isFalse();
}
}
/** 原业务异常不能被诊断替换,回滚完成时仍有同一作用域,结束后清理。 */
@Test
void originalFailureRollsBackAndEscapesUnchangedAfterDiagnosticCleanup() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var probe=context.getBean(Probe.class);
var failure=new IllegalStateException("原业务失败");probe.failure=failure;
assertThat(catchThrowable(()->context.getBean(QueryProbe.class).detail("rollback",null))).isSameAs(failure);
assertThat(probe.events).containsExactly("body","rollback-before","rollback-after");
assertThat(probe.transactionScopes).containsOnly(probe.bodyScope);
assertThat(context.getBean(ProbeMapper.class).count()).isZero();
assertThat(AppointmentWorkDiagnostics.active()).isFalse();
}
}
/** 已有HTTP作用域由查询复用,同步开启子作用域后恢复外层,均在finally中关闭。 */
@Test
void httpScopeIsReusedAndNestedSyncRestoresItWithoutThreadLocalResidue() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var probe=context.getBean(Probe.class);
try(var outer=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) {
String outerId=AppointmentWorkDiagnostics.currentScopeId();
context.getBean(QueryProbe.class).detail("outer",null);
assertThat(probe.bodyScope).isEqualTo(outerId);
assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(outerId);
context.getBean(SyncProbe.class).synchronize(new SyncRequest(null,1));
assertThat(probe.syncScope).isNotNull().isNotEqualTo(outerId);
assertThat(probe.syncParent).isEqualTo(outerId);
assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(outerId);
assertThat(outer.snapshot().scopeId()).isEqualTo(outerId);
assertThat(outer.snapshot().metrics().get("TRANSACTION_ADVISOR").calls()).isEqualTo(1);
}
assertThat(AppointmentWorkDiagnostics.active()).isFalse();
}
}
/** 请求外的连接及SQL不创建诊断作用域,也不会被计入之后新开的请求。 */
@Test
void outOfScopeDatabaseWorkDoesNotCreateOrPolluteTheNextScope() throws Exception {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
context.getBean(ProbeMapper.class).count();
try(var connection=context.getBean(DataSource.class).getConnection()) { assertThat(connection.isClosed()).isFalse(); }
assertThat(AppointmentWorkDiagnostics.active()).isFalse();
try(var scope=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) {
assertThat(scope.snapshot().metrics()).isEmpty();
context.getBean(ProbeMapper.class).count();
assertThat(scope.snapshot().metrics()).isNotEmpty();
}
}
}
/** 真正的afterCompletion分别记录提交和回滚,不能把方法返回时间当作事务完成时间。 */
@Test
void realTransactionCompletionRecordsStatusAndOffsetBeforeTheHttpScopeCloses() {
for(boolean rollback:List.of(false,true)) {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var probe=context.getBean(Probe.class);
var original=new IllegalStateException("真实事务回滚");
if(rollback)probe.failure=original;
try(var scope=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) {
var error=catchThrowable(()->context.getBean(QueryProbe.class).detail("completion",null));
if(rollback)assertThat(error).isSameAs(original);else assertThat(error).isNull();
assertThat(probe.bodySnapshot.transactionObserved()).isTrue();
assertThat(probe.bodySnapshot.completionStatus()).isEqualTo(-1);
assertThat(probe.bodySnapshot.completionOffsetNanos()).isEqualTo(-1);
var completed=scope.snapshot();
assertThat(completed.transactionObserved()).isTrue();
assertThat(completed.completionStatus()).isEqualTo(rollback
?TransactionSynchronization.STATUS_ROLLED_BACK:TransactionSynchronization.STATUS_COMMITTED);
assertThat(completed.completionOffsetNanos()).isBetween(0L,completed.elapsedNanos());
assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(completed.scopeId());
}
assertThat(AppointmentWorkDiagnostics.active()).isFalse();
assertThat(context.getBean(ProbeMapper.class).count()).isEqualTo(rollback?0:1);
}
}
}
interface ProbeMapper {
/** 测试事务只向本地内存表写一行,验证原事务结果。 */
@Insert("INSERT INTO diagnostic_probe(id) VALUES(1)")
int insert();
/** 直接执行两次查询,确认实际事务复用同一H2会话。 */
@Select("SELECT SESSION_ID()") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
long sessionId();
/** 在事务外核对提交或回滚后的真实行数。 */
@Select("SELECT COUNT(*) FROM diagnostic_probe") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
int count();
}
static final class Probe {
final List<String> events=new ArrayList<>();
final List<String> transactionScopes=new ArrayList<>();
String bodyScope;
String syncScope;
String syncParent;
AppointmentWorkDiagnostics.Snapshot bodySnapshot;
long firstSession;
long secondSession;
boolean transactionActive;
boolean boundResource;
RuntimeException failure;
}
public static class QueryProbe extends AppointmentWorkQueryService {
private final ProbeMapper mapper;
private final Probe probe;
private final DataSource dataSource;
/** 使用生产查询服务类型及原方法签名触发生产pointcut,不新增诊断匹配规则。 */
QueryProbe(ProbeMapper mapper,Probe probe,DataSource dataSource) {
super(mock(AppointmentWorkStore.class),mock(AppointmentWorkFactGateway.class));
this.mapper=mapper;this.probe=probe;this.dataSource=dataSource;
}
/** 只替换测试业务体,真实Spring事务、连接获取和生产诊断链不替换。 */
@Override @Transactional(readOnly=false)
public WorkDetail detail(String key,Actor actor) {
AppointmentWorkDiagnostics.observeTransaction();
probe.bodyScope=AppointmentWorkDiagnostics.currentScopeId();probe.events.add("body");
probe.transactionActive=TransactionSynchronizationManager.isActualTransactionActive();
probe.boundResource=TransactionSynchronizationManager.hasResource(dataSource);
mapper.insert();probe.firstSession=mapper.sessionId();probe.secondSession=mapper.sessionId();
probe.bodySnapshot=AppointmentWorkDiagnostics.currentSnapshot();
if(probe.failure!=null)throw probe.failure;
return null;
}
}
public static class SyncProbe extends AppointmentWorkSyncService {
private final Probe probe;
/** 使用原同步服务类型与方法,使生产Advisor执行真正的嵌套作用域规则。 */
SyncProbe(Probe probe) { super(mock(AppointmentWorkStore.class),mock(AppointmentWorkFactGateway.class));this.probe=probe; }
/** 同步探针不修改业务数据,仅记录事务内当前作用域。 */
@Override @Transactional
public SyncResult synchronize(SyncRequest request) {
probe.syncScope=AppointmentWorkDiagnostics.currentScopeId();
probe.syncParent=AppointmentWorkDiagnostics.currentSnapshot().parentScopeId();
return new SyncResult(0,0,null,true);
}
}
static class ObservingTransactionManager extends DataSourceTransactionManager {
private final Probe probe;
/** 在原事务管理器提交及回滚两侧观察诊断作用域,不替换数据库动作。 */
ObservingTransactionManager(DataSource dataSource,Probe probe) { super(dataSource);this.probe=probe; }
/** 诊断必须覆盖真实提交前后,不能只包住业务方法体。 */
@Override protected void doCommit(DefaultTransactionStatus status) {
observe("commit-before");super.doCommit(status);observe("commit-after");
}
/** 原异常回滚路径同样保持作用域到数据库回滚完成。 */
@Override protected void doRollback(DefaultTransactionStatus status) {
observe("rollback-before");super.doRollback(status);observe("rollback-after");
}
/** 只记录执行顺序和内部scope标识,不收集凭据、SQL参数或业务资料。 */
private void observe(String phase) { probe.events.add(phase);probe.transactionScopes.add(AppointmentWorkDiagnostics.currentScopeId()); }
}
@Configuration(proxyBeanMethods=false)
@EnableTransactionManagement(proxyTargetClass=true)
@Import(AppointmentWorkDiagnosticsConfiguration.class)
static class Config {
/** 使用独立内存H2,池大小有界且由Spring负责关闭。 */
@Bean(destroyMethod="close") HikariDataSource dataSource() {
var source=new HikariDataSource();source.setJdbcUrl("jdbc:h2:mem:diagnostic-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
source.setUsername("sa");source.setPassword("");source.setMaximumPoolSize(2);source.setMinimumIdle(0);return source;
}
/** 本地测试共享观测记录,不影响生产诊断实现。 */
@Bean Probe probe() { return new Probe(); }
/** 将生产后处理后的同一个DataSource交给真实Spring事务管理器。 */
@Bean ObservingTransactionManager transactionManager(DataSource dataSource,Probe probe) { return new ObservingTransactionManager(dataSource,probe); }
/** 使用生产注册的MyBatis插件与同一个DataSource,禁止另造未代理工厂。 */
@Bean SqlSessionFactory sqlSessionFactory(DataSource dataSource,List<Interceptor> interceptors) throws Exception {
new JdbcTemplate(dataSource).execute("CREATE TABLE diagnostic_probe(id INT PRIMARY KEY)");
var configuration=new MybatisConfiguration();configuration.addMapper(ProbeMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(configuration);factory.setDataSource(dataSource);
factory.setPlugins(interceptors.toArray(Interceptor[]::new));return factory.getObject();
}
/** 模板按原事务绑定复用连接,不直接创建业务SQL连接。 */
@Bean SqlSessionTemplate template(SqlSessionFactory factory) { return new SqlSessionTemplate(factory); }
/** 真实Mapper由SqlSessionTemplate生成。 */
@Bean ProbeMapper mapper(SqlSessionTemplate template) { return template.getMapper(ProbeMapper.class); }
/** 原查询类型交给Spring自动代理,测试不手动构造ProxyFactory。 */
@Bean QueryProbe queryProbe(ProbeMapper mapper,Probe probe,DataSource dataSource) { return new QueryProbe(mapper,probe,dataSource); }
/** 原同步类型交给同一生产Advisor自动匹配。 */
@Bean SyncProbe syncProbe(Probe probe) { return new SyncProbe(probe); }
}
}
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.*;
import org.slf4j.*;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
class AppointmentWorkDiagnosticsTest {
/** 每项隔离测试都必须释放线程上下文。 */
@AfterEach void noLeak(){assertFalse(active());MDC.clear();}
/** 同桶嵌套按完成次序累加,首尾时刻仍覆盖外层且self不重复相加。 */
@Test void nestedSameBucketTracksInclusiveSelfAndTrueOuterOffsets(){
var clock=new AtomicLong();
try(var scope=new Scope(Origin.QUERY,Route.INTERNAL,clock::get)){
clock.set(10);var outer=span(Stage.QUERY_FACTS);clock.set(20);var inner=span(Stage.QUERY_FACTS);
clock.set(50);inner.close();clock.set(100);outer.close();outer.close();
var metric=scope.snapshot().metrics().get("QUERY_FACTS");
assertEquals(2,metric.calls());assertEquals(120,metric.inclusiveNanos());assertEquals(90,metric.selfNanos());
assertEquals(10,metric.firstOffsetNanos());assertEquals(100,metric.lastOffsetNanos());
}
}
/** 独立同步scope仅以父标识关联,父self不假称已扣除另一作用域。 */
@Test void independentScopeRestoresParentAndDoesNotSubtractItsIntervals(){
var clock=new AtomicLong();MDC.put("traceId","safe-trace");
try(var outer=new Scope(Origin.HTTP,Route.ITEMS,clock::get)){
var span=span(Stage.QUERY_FACTS);clock.set(10);
try(var child=new Scope(Origin.SYNC,Route.INTERNAL,clock::get)){
assertEquals(outer.snapshot().scopeId(),child.snapshot().parentScopeId());
try(var nested=span(Stage.SYNC_BODY)){clock.set(50);}
}
assertEquals(outer.snapshot().scopeId(),currentScopeId());clock.set(100);span.close();
assertEquals(100,outer.snapshot().metrics().get("QUERY_FACTS").selfNanos());
}
assertEquals("safe-trace",MDC.get("traceId"));
}
/** 快速异常即使被上层映射为HTTP200,仍输出一次失败摘要且不含原异常文本。 */
@Test void swallowedFastFailureStillMarksScopeAndPreservesOriginalException(){
try(var log=new LogCapture();var scope=open(Origin.HTTP,Route.COMMAND)){
var error=new IllegalStateException("customer-secret-business-id");
assertSame(error,assertThrows(IllegalStateException.class,()->measure(Stage.QUERY_FACTS,()->{throw error;})));
scope.status(200);assertTrue(scope.snapshot().failed());scope.close();
assertEquals(1,log.appender.list.size());var text=log.appender.list.get(0).getFormattedMessage();
assertTrue(text.contains("failed=true"));assertFalse(text.contains("customer-secret"));assertNull(log.appender.list.get(0).getThrowableProxy());
}
}
/** 诊断日志设施自身抛错时清理仍完成,不向业务调用传播。 */
@Test void loggingFailureDoesNotEscapeOrLeakScope(){
var logger=(Logger)LoggerFactory.getLogger(AppointmentWorkDiagnostics.class);
var failing=new AppenderBase<ILoggingEvent>(){
/** 模拟日志输出设施故障,不借业务异常路径。 */
@Override protected void append(ILoggingEvent event){throw new AssertionError("logger failed");}
};failing.start();logger.addAppender(failing);
try{var scope=open(Origin.QUERY,Route.INTERNAL);scope.failed();assertDoesNotThrow(scope::close);assertFalse(active());}
finally{logger.detachAppender(failing);failing.stop();}
}
/** 快速成功无摘要,返回对象和调用次数均保持原样。 */
@Test void successfulFastOperationIsTransparentAndOutOfScopeIsNoOp(){
var value=new Object();var calls=new AtomicLong();
assertSame(value,measure(Stage.QUERY_FACTS,()->{calls.incrementAndGet();return value;}));assertFalse(active());
try(var log=new LogCapture();var scope=new Scope(Origin.QUERY,Route.INTERNAL,()->0)){
assertSame(value,measure(Stage.QUERY_FACTS,()->{calls.incrementAndGet();return value;}));scope.close();assertTrue(log.appender.list.isEmpty());
}assertEquals(2,calls.get());
}
/** 非诊断格式的MDC值不进入摘要,原MDC内容不会被修改。 */
@Test void arbitraryMdcTextIsExcludedWithoutChangingCallerContext(){
MDC.put("requestId","customer password\nsecret");MDC.put("traceId","a".repeat(32));
try(var scope=open(Origin.QUERY,Route.INTERNAL)){
assertNull(scope.snapshot().requestId());assertEquals("a".repeat(32),scope.snapshot().traceId());
}assertEquals("customer password\nsecret",MDC.get("requestId"));
}
static final class LogCapture implements AutoCloseable{
final Logger logger=(Logger)LoggerFactory.getLogger(AppointmentWorkDiagnostics.class);
final ListAppender<ILoggingEvent> appender=new ListAppender<>();
/** 只捕获本诊断logger,不改变全局日志配置。 */
LogCapture(){appender.start();logger.addAppender(appender);}
/** 释放本测试附加器。 */
@Override public void close(){logger.detachAppender(appender);appender.stop();}
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.*;
import org.apache.ibatis.builder.StaticSqlSource;
import org.apache.ibatis.session.*;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
class AppointmentWorkMyBatisDiagnosticsTest {
/** 插件不得重试、替换原异常或把动态语句标识与参数带入诊断。 */
@Test void executorFailureIsTransparentAndBucketContextDoesNotLeak()throws Exception{
var target=mock(Executor.class);var proxy=(Executor)new AppointmentWorkMyBatisDiagnostics().plugin(target);
var config=new Configuration();
var statement=new MappedStatement.Builder(config,"private-customer-secret",new StaticSqlSource(config,"SELECT private_payload"),SqlCommandType.SELECT).build();
var failure=new IllegalStateException("private-business-failure");
when(target.query(statement,"private-token",RowBounds.DEFAULT,null)).thenThrow(failure);
try(var scope=open(Origin.QUERY,Route.INTERNAL)){
assertSame(failure,assertThrows(IllegalStateException.class,()->proxy.query(statement,"private-token",RowBounds.DEFAULT,null)));
assertEquals(1,scope.snapshot().metrics().get("DB_EXECUTOR_QUERY:OTHER").failures());
assertFalse(scope.snapshot().toString().contains("private-"));verify(target).query(statement,"private-token",RowBounds.DEFAULT,null);
}
var next=new MappedStatement.Builder(config,"com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard",new StaticSqlSource(config,"SELECT 1"),SqlCommandType.SELECT).build();
var expected=List.of(new Object());when(target.query(next,null,RowBounds.DEFAULT,null)).thenReturn(expected);
try(var scope=open(Origin.QUERY,Route.INTERNAL)){
assertSame(expected,proxy.query(next,null,RowBounds.DEFAULT,null));
assertEquals(1,scope.snapshot().metrics().get("DB_EXECUTOR_QUERY:GUARD_READ").calls());assertFalse(scope.snapshot().metrics().containsKey("DB_EXECUTOR_QUERY:OTHER"));
}assertFalse(active());verify(target).query(next,null,RowBounds.DEFAULT,null);verifyNoMoreInteractions(target);
}
}
@@ -259,12 +259,6 @@ class FbaAppointmentPersistenceTest {
@Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List<Long> 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);}
@@ -1,244 +0,0 @@
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<List<SourceRef>>();
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<SourceRef>();
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<SourceRef>();
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<String> 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(); }
}
}
@@ -15,6 +15,16 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDeliveryProviderTest {
/** 在真实provider上启用计时,固定批量阶段各执行一次,后续原业务断言继续验证结果。 */
private static List<Fact> diagnosticRead(AppointmentWorkDeliveryProvider provider,List<FactRequest> requests){
try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open(
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP,
com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)){
var result=provider.read(requests);
for(var stage:List.of("DELIVERY_SNAPSHOTS","DELIVERY_ROWS","DELIVERY_RECEIVING","DELIVERY_IMPACTS","DELIVERY_ASSEMBLY"))assertEquals(1,scope.snapshot().metrics().get(stage).calls(),stage);
return result;
}
}
@Test void confirmedCustomerIndependentFailedPlanAndUnplannedSiblingRemainSeparate() {
String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles";
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var receiving=receiving();
@@ -33,7 +43,7 @@ class AppointmentWorkDeliveryProviderTest {
when(commitments.impactTokens(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));});
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving);
var requests=List.of("A","B","D").stream().map(c->new FactRequest("W"+c,Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:"+c,key,null,List.of(key),List.of(),List.of(new SourceReference(c,"ORDER:1","FBA:ONT8")))).toList();
var facts=provider.read(requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2);
var facts=diagnosticRead(provider,requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2);
assertEquals(confirmedAt.toLocalDate(),aFact.confirmed().date());assertNull(aFact.target());assertEquals(PlanStatus.CONFIRMED,aFact.planStatus());assertTrue(aFact.blockReasons().isEmpty());
assertEquals(failedDate,bFact.target().date());assertNull(bFact.confirmed());assertEquals(PlanStatus.TARGET_SET,bFact.planStatus());assertEquals("沿原批次重新预约",bFact.action().label());
assertEquals(PlanStatus.UNPLANNED,dFact.planStatus());assertNull(dFact.target());assertNull(dFact.confirmed());assertTrue(dFact.blockReasons().isEmpty());
@@ -11,32 +11,11 @@ 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.<List<AppointmentExternalSourceGateway.OrderRef>>getArgument(0).stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList());
when(sources.resolveBatch(anyList())).thenAnswer(c->{
var ref=c.<List<AppointmentCoverageService.SourceRef>>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=provider(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)));
@@ -54,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.openRequirements(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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);
scoped(sources);var provider=provider(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)));
@@ -93,87 +72,73 @@ 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(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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);
scoped(sources);var provider=provider(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.openRequirements(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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);
scoped(sources);var provider=provider(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.openRequirements(java.util.List.of()).read(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.openRequirements(java.util.List.of()).read(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);
scoped(sources);var provider=provider(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.openRequirements(java.util.List.of()).read(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=provider(sources,cargo,appointments,mapping);
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(java.util.List.of());verifyNoInteractions(sources,cargo,appointments,mapping);
var reader=provider.openRequirements();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.<List<String>>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.<List<List<AppointmentCoverageService.SourceRef>>>getArgument(0);return (java.util.function.IntFunction<List<com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse>>)(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.resolveOrderGroups(anyList())).thenAnswer(c->c.<List<List<AppointmentExternalSourceGateway.OrderRef>>>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());
when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0)));
return scope;
});
}