feat(selfop): add warehouse dashboard and exact inventory drilldowns

This commit is contained in:
2026-10-13 00:45:39 +08:00
parent a84a9b730e
commit a41c271a67
27 changed files with 827 additions and 48 deletions
@@ -23,7 +23,7 @@ class PlatformAnalyticsControllerTest {
private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));}
@Test void rejectsCustomerEvenWithReadPermission(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var controller=new PlatformAnalyticsController(service,collection);
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null));
assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null));
@@ -31,7 +31,7 @@ class PlatformAnalyticsControllerTest {
}
@Test void writePermissionAloneDoesNotAuthorizeAnyRead(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var controller=new PlatformAnalyticsController(service,collection);
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null));
assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null));
@@ -40,7 +40,7 @@ class PlatformAnalyticsControllerTest {
}
@Test void allElevenReadEndpointsShareTheIndependentGuard(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var c=new PlatformAnalyticsController(service,collection);var user=platform();
var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform();
c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null);
@@ -56,7 +56,7 @@ class PlatformAnalyticsControllerTest {
}
@Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary")
.param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","CUSTOM").param("startDate","2026-10-01")
.param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22")
@@ -68,7 +68,7 @@ class PlatformAnalyticsControllerTest {
}
@Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO")
.param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk());
var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture());
@@ -76,7 +76,7 @@ class PlatformAnalyticsControllerTest {
}
@Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W")
.param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk());
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class);
@@ -84,4 +84,14 @@ class PlatformAnalyticsControllerTest {
assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold());
verifyNoInteractions(collection);
}
@Test void dashboardUsesIndependentReadGuardAndRejectsArbitraryTopN()throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);var dashboard=mock(DashboardQueryService.class);
var controller=new PlatformAnalyticsController(service,collection,dashboard);
var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
assertThrows(ResponseStatusException.class,()->controller.dashboard(customer,null,java.util.Map.of()));verifyNoInteractions(dashboard);
assertThrows(com.baiyue.common.core.exception.BizException.class,()->controller.dashboard(platform(),null,java.util.Map.of("topN","500")));
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(identity()).build();
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/dashboard").param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","TODAY")).andExpect(status().isOk());
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(dashboard).dashboard(q.capture());assertEquals("TODAY",q.getValue().rangePreset());assertNull(q.getValue().size());
}
}
@@ -247,14 +247,24 @@ class AnalyticsQueryServiceTest {
}
@Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){
var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null);
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null)));
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null,null,null,null,null)));
assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options()));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null,null,null,null,null)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1,null,null,null,null)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null)));
verifyNoInteractions(mapper);
}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null);}
@Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD");
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o));
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
var q=pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1);
assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->service.inventoryCoverage(q,o)).getMessage());
verify(mapper,never()).inventoryCount(any(),any());verify(mapper,never()).inventoryPage(any(),any());
// Normal inventory diagnostics still return the same fixed but stale version.
assertEquals("STALE",service.inventoryCoverage(q,options()).context().inventoryStatus());
}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){
BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales);
return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
@@ -77,7 +77,8 @@ class AnalyticsReadMapperTest {
assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
assertTrue(sql.contains("GROUP BY customer_code,sku_code"));
assertTrue(sql.indexOf("GROUP BY customer_code,sku_code")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql);
if("HOT".equals(kind))assertTrue(sql.indexOf("LIMIT ? OFFSET ?")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
else assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql);
}
String hot=bound(c,"page",Map.of("p",request("HOT"))).getSql();
assertTrue(hot.contains("FROM result WHERE window_quantity>0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT"));
@@ -90,10 +91,11 @@ class AnalyticsReadMapperTest {
assertTrue(stats.contains("LEAST(SUM(CASE WHEN eligible=1 THEN amount ELSE 0 END) OVER (),100) total"));
assertTrue(stats.contains("excluded_count"));assertFalse(stats.contains(" LIMIT "));
assertFalse(stats.contains("ranged AS"));assertFalse(stats.contains("TIMESTAMPDIFF"));
if("HOT".equals(kind))assertFalse(stats.contains("ll_selfop_inventory_snapshot"));
}
}
@Test void inventoryHistoryOnlyEnrichesQualifiedPageAndNeverCountsOrOwnerTotals(){
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90);
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,null,null);
String page=bound(c,"inventoryPage",Map.of("p",p,"o",o)).getSql();
assertTrue(page.contains("paged AS (SELECT * FROM result WHERE above_threshold=1 ORDER BY"));
assertTrue(page.indexOf("LIMIT ? OFFSET ?")<page.indexOf("last_sales AS"));
@@ -103,6 +105,19 @@ class AnalyticsReadMapperTest {
assertFalse(bound(c,"ownerTotals",Map.of("p",p,"o",o)).getSql().contains("last_sales"));
immutableOnly(page);
}
@Test void distributionCountCannotUseUnfilteredKeysAndStockOnlyCountSkipsSales(){
var c=config();var p=request("INVENTORY");
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"DAYS_0_30","COVERAGE_DISTRIBUTION_V1",null,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null))){
String count=bound(c,"inventoryCount",Map.of("p",p,"o",o)).getSql();
assertTrue(count.contains("FROM result WHERE"));assertTrue(count.contains("ll_selfop_sku_daily_sales"));
}
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
String count=bound(c,"inventoryCount",Map.of("p",p,"o",new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null))).getSql();
assertFalse(count.contains("ll_selfop_sku_daily_sales"));assertTrue(count.contains("i.quantity_unit='PCS'"));
assertTrue(count.contains("snapshot_run_id=?"));assertTrue(count.contains("st.amount>0"));
}
}
@Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){
String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql();
assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL"));
@@ -94,7 +94,9 @@ public class AnalyticsSqlSemanticTest {
assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0));
var row=rows.get(0);
assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount());
assertTrue(page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)).isEmpty());
var hot=page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20));
assertEquals(2,hot.size());assertTrue(hot.stream().allMatch(r->r.windowQuantity().compareTo(new BigDecimal("5"))==0));
assertTrue(hot.stream().allMatch(r->"UNKNOWN".equals(r.inventoryQuality())));
}
@Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
@@ -106,6 +108,30 @@ public class AnalyticsSqlSemanticTest {
assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d());
assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount());
}
@Test void hotCompleteWindowIgnoresTodayUnitButTodayValueRemainsUnknown() throws Exception {
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
daily(11,"W","C","SKU",D.minusDays(1),"12");daily(11,"W","C","SKU",D,"500");
try(var sql=schema.createStatement()){sql.executeUpdate("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='BOX' WHERE business_date=DATE '2026-10-12'");}
var rows=page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20));
assertEquals(1,rows.size());assertDecimal("12",rows.get(0).windowQuantity());
assertDecimal("12",rows.get(0).sales30Quantity());assertNull(rows.get(0).todayQuantity());
var stats=rankingStats(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20));
assertEquals(1L,stats.get(0).total());
}
@Test void stockOnlyCountMatchesPageForFrozenAndDefectiveWithoutSalesDependency() throws Exception {
profile(22,"C","VALID","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
profile(22,"C","BAD-UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");
profile(22,"C","MISSING","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
stockExact("VALID",0,"5","1","4","0","VALID",null);stockExact("VALID",1,"8","0","8","0","VALID",null);
stockExact("BAD-UNIT",1,"8","0","8","0","VALID",null);
stockExact("MISSING",1,null,"0",null,"0","UNKNOWN","FIELD_MISSING");
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20);
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
var o=new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null);
assertEquals(1L,((Number)literalQuery("inventoryCount",p,o).get(0)).longValue());
assertEquals(List.of("VALID"),inventory(attention,null,null,0,20).stream().map(Inventory->Inventory.skuCode()).toList());
}
}
@Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING");
@@ -132,7 +158,7 @@ public class AnalyticsSqlSemanticTest {
var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size());
assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays());
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20);
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300);
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300,null,null,null,null);
assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
Map<String,Object> totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue());
assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known"));
@@ -141,7 +167,7 @@ public class AnalyticsSqlSemanticTest {
profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null);
daily(11,"W","C","NEW",D.minusDays(1),"5");
var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound());
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30));
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null));
assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical"));
}
@@ -150,17 +176,17 @@ public class AnalyticsSqlSemanticTest {
profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
}
var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20);
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null)));
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null)));
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null)));
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null)).get(0)).longValue());
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null,null,null,null,null)));
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null,null,null,null,null)));
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null,null,null,null,null)));
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)).get(0)).longValue());
}
@Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception {
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");
daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null);
var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity());
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30));
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null));
assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
}
@Test void yearlySparseSummaryPreservesOwnerIdentityAndCompletePartialUnknownSpanCounts() throws Exception {
@@ -196,9 +222,13 @@ public class AnalyticsSqlSemanticTest {
profile(22,"C","ZERO","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");stock("C","ZERO",0,"0");
profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stock("C","NEW",0,"10");
profile(22,"C","UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),null);stock("C","UNIT",0,"10");
// Stock's missing unit must not create a sales-unit exclusion: the complete
// sales window still proves no outbound for UNIT. A broken sales profile is different.
profile(11,"C","SALES-UNIT","VALID_OUTBOUND",utc(D.minusDays(40),0),null);
daily(11,"W","C","SALES-UNIT",D.minusDays(1),"1000");
var hot=request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20);
var hotStats=rankingStats(hot);assertTrue(hotStats.stream().allMatch(s->s.total()==100L));
assertEquals(2L,hotStats.stream().filter(s->"ZERO_OUTBOUND".equals(s.reason())).findFirst().orElseThrow().excludedCount());
assertEquals(3L,hotStats.stream().filter(s->"ZERO_OUTBOUND".equals(s.reason())).findFirst().orElseThrow().excludedCount());
assertEquals(1L,hotStats.stream().filter(s->"UNIT_UNKNOWN_OR_CONFLICT".equals(s.reason())).findFirst().orElseThrow().excludedCount());
assertEquals("B",page(hot).get(0).customerCode());
var slowStats=rankingStats(request("SLOW",D.minusDays(30),D,null,null,utc(D,8),0,20));
@@ -218,12 +248,12 @@ public class AnalyticsSqlSemanticTest {
assertEquals(D.minusDays(60),second.get(0).lastOutbound());
}
@SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null));
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null));
}
private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));}
@SuppressWarnings("unchecked")
private List<AnalyticsReadRows.Inventory> inventory(String attention,Integer threshold,String sort,long offset,int size){
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold));
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold,null,null,null,null));
}
@SuppressWarnings("unchecked")
private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class DashboardPolicyTest {
static final LocalDate DAY=LocalDate.of(2026,10,13);
static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z");
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());}
@Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){
var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L));
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows);var summary=SalesBucketPolicy.aggregate(points);
assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus());
}
@Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){
var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true);
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of());
assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(BigDecimal.ZERO,points.get(0).outboundQuantity());
assertEquals("PARTIAL",points.get(1).dataStatus());assertEquals(BigDecimal.ZERO,points.get(1).observedQuantity());assertNull(points.get(1).outboundQuantity());
var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity());
}
@Test void strictInventoryDrilldownExpiresBeforeContextWhileOrdinaryInventoryRemainsAllowed(){
var c=context(NOW.minusSeconds(86400),NOW,true);var q=query(true);var strict=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,"DASHBOARD");
Instant fresh=NOW.plusSeconds(60);assertEquals(fresh,CoverageDistributionPolicy.expiresAt(c,fresh));
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh.minusSeconds(1)));
assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh)).getMessage());
assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(query(false),strict,c,fresh,NOW));
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null),c,fresh,fresh.plusSeconds(1)));
assertNull(CoverageDistributionPolicy.expiresAt(context(null,null,false),fresh));
}
@Test void bucketSelectionAndAttentionCannotSilentlyBroadenToWarehouse(){
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null),
new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null),
new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null),
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null),
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null)))assertThrows(BizException.class,()->o.validate("INVENTORY",query(true)));
assertThrows(BizException.class,()->DashboardRequest.validateParameterNames(Set.of("sourceAccount","warehouseCode","topN")));
assertEquals("LAST_15_DAYS",DashboardRequest.normalize(query(false)).rangePreset());
}
private static AnalyticsQuery query(boolean pinned){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,null,null,null,pinned?11L:null,pinned?22L:null,pinned?"a".repeat(64):null,pinned?"b".repeat(64):null,pinned?NOW:null);}
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesRangePolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DashboardQueryServiceTest {
@Test void oneContextKeepsThirtyDaySalesAndHotAvailableWhenInventoryUnavailable()throws Exception{
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
var snapshot=new AnalyticsQueryService.Snapshot(q,null,null,true,true,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD");
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p);
when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L)));
when(mapper.statistics(any(),any())).thenReturn(Map.of("total_skus",200L,"hot_eligible",200L,"hot_unknown",0L));
when(mapper.rankings(any(),any())).thenReturn(List.of(new DashboardReadMapper.Ranked("HOT","A","X","Product",new BigDecimal("30"),new BigDecimal("30"),1L,null,null,null,null,null,null)));
var result=service.dashboard(q);
assertEquals(0,BigDecimal.ONE.compareTo(result.kpis().averageDailyOutboundQuantity30d().value()));
assertEquals(200L,result.hotSkus().eligibleCount());assertEquals(1,result.hotSkus().items().size());assertEquals("A",result.hotSkus().items().get(0).customerCode());
assertNull(result.coverageDistribution().candidateSkuCount());assertTrue(result.coverageDistribution().buckets().stream().allMatch(b->b.skuCount()==null));
assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(30),result.context().fixedWindowStartDate());
verify(mapper).daily(any(),any(),any());verify(mapper).statistics(any(),any());verify(mapper).rankings(any(),any());verifyNoMoreInteractions(mapper);
var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext"));
assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly());
}
}
@@ -0,0 +1,88 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.mapping.*;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.sql.*;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** Production SQL on process-local synthetic H2, never a real environment or outbound HTTP. */
class DashboardSqlSemanticTest {
private Connection connection;private SqlSession session;private Configuration config;private int sequence;
private static final LocalDate D=LocalDate.of(2026,10,13);
@BeforeEach void setup()throws Exception{
var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:dashboard_"+UUID.randomUUID().toString().replace("-","")+";MODE=MySQL","sa","");connection=ds.getConnection();
try(var s=connection.createStatement()){
s.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
s.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
s.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
s.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))");
}
config=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DashboardReadMapper.class);config.addMapper(AnalyticsReadMapper.class);
session=new SqlSessionFactoryBuilder().build(config).openSession();
}
@AfterEach void close()throws Exception{if(session!=null)session.close();if(connection!=null)connection.close();}
@Test void sevenExclusiveBucketsConserveCandidatesAndEveryInventoryDrilldownMatches()throws Exception{
fixture("NOOUT","100","100",null);fixture("FROZEN","50","0",null);fixture("UNKNOWN",null,null,null);
fixture("THIRTY","30","30","30");fixture("THIRTY04","30.04","30.04","30");fixture("ZERO","0","0",null);
var stats=stats();assertEquals(5,count(stats,"candidate_skus"));assertEquals(1,count(stats,"excluded_zero_skus"));
assertEquals(1,count(stats,"bucket_days_0_30"));assertEquals(1,count(stats,"bucket_days_30_60"));assertEquals(1,count(stats,"bucket_no_outbound_30d"));
assertEquals(1,count(stats,"bucket_no_available"));assertEquals(1,count(stats,"bucket_unknown"));
long sum=0;for(String bucket:CoverageDistributionPolicy.BUCKETS){long n=count(stats,"bucket_"+bucket.toLowerCase(Locale.ROOT));sum+=n;
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null);
assertEquals(n,((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",o)).get(0)).longValue(),bucket);
}assertEquals(5,sum);
var finite=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null);
assertEquals(count(stats,"long_eligible"),((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",finite)).get(0)).longValue());
assertEquals(2,count(stats,"no_outbound_eligible"));
var ranked=ranks();assertEquals("THIRTY04",ranked.stream().filter(r->r.rankCode().equals("LONG")).max(Comparator.comparing(DashboardReadMapper.Ranked::mainValue)).orElseThrow().skuCode());
assertTrue(ranked.stream().anyMatch(r->r.rankCode().equals("NO_OUTBOUND")&&r.skuCode().equals("FROZEN")));
}
@Test void exactNinetyBoundaryAndUnknownInventoryRemainDistinct()throws Exception{
fixture("NINETY","90","90","30");fixture("NINETY04","90.04","90.04","30");fixture("INFINITE","999","999",null);
var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_30d"));
assertEquals(2,count(s,"long_eligible"));assertTrue(ranks().stream().filter(r->r.rankCode().equals("LONG")).noneMatch(r->r.skuCode().equals("INFINITE")));
}
@Test void twoHundredHotCandidatesCountBeyondTopHundredAndKeepTopTenIdentical()throws Exception{
for(int i=0;i<200;i++){
String sku=String.format("SKU-%03d",i);profile(11,"C",sku,"PCS");if(i%3!=0)profile(22,"C",sku,i%3==1?"BOX":null);
daily("C",sku,D.minusDays(1),Integer.toString(i+1),"PCS");
}
daily("C","SKU-199",D,"999","BOX");
assertEquals(200,count(stats(),"hot_eligible"));var top=ranks().stream().filter(r->r.rankCode().equals("HOT")).sorted(Comparator.comparing(DashboardReadMapper.Ranked::mainValue).reversed()).toList();assertEquals(10,top.size());
var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT");
List<?> page=query(AnalyticsReadMapper.class,"page",Map.of("p",hot));assertEquals(10,page.size());
for(int i=0;i<10;i++){var item=(AnalyticsReadRows.Aggregate)page.get(i);assertEquals(top.get(i).skuCode(),item.skuCode());assertEquals(0,top.get(i).mainValue().compareTo(item.windowQuantity()));}
}
@Test void warehouseDailyIncludesBothOwnersWithoutSkuObservationAndNoQualityMultiplication()throws Exception{
profile(11,"A","SAME","PCS");profile(11,"B","SAME","PCS");daily("A","SAME",D.minusDays(1),"2","PCS");daily("B","SAME",D.minusDays(1),"5","PCS");
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(2),"chartUntil",D.plusDays(1)));
assertEquals(1,rows.size());var row=(DashboardReadMapper.Day)rows.get(0);assertEquals(0,new BigDecimal("7").compareTo(row.observedQuantity()));assertEquals(0L,row.invalidRowCount());
}
private void fixture(String sku,String physical,String available,String sold)throws Exception{profile(11,"C",sku,"PCS");profile(22,"C",sku,"PCS");stock(sku,physical,available);if(sold!=null)daily("C",sku,D.minusDays(1),sold,"PCS");}
private void profile(long run,String owner,String sku,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,sku);p.setString(7,unit);p.setString(8,run==11?"VALID_OUTBOUND":"INVENTORY_OBSERVATION");p.setLong(9,run);p.setObject(10,D.minusDays(40).atStartOfDay().minusHours(8));p.executeUpdate();}}
private void daily(String owner,String sku,LocalDate day,String value,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}}
private void stock(String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}}
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
@SuppressWarnings("unchecked") private Map<String,Object> stats(){Map<String,Object> result=new HashMap<>();((Map<String,Object>)query(DashboardReadMapper.class,"statistics",Map.of("p",request(),"o",options())).get(0)).forEach((k,v)->result.put(k.toLowerCase(Locale.ROOT),v));return result;}
@SuppressWarnings("unchecked") private List<DashboardReadMapper.Ranked> ranks(){return (List<DashboardReadMapper.Ranked>)(List<?>)query(DashboardReadMapper.class,"rankings",Map.of("p",request(),"o",options()));}
private static long count(Map<String,Object> stats,String key){return ((Number)stats.get(key)).longValue();}
private List<?> query(Class<?> mapper,String method,Map<String,Object> args){
var original=config.getMappedStatement(mapper.getName()+"."+method);var bound=original.getBoundSql(args);var meta=org.apache.ibatis.reflection.SystemMetaObject.forObject(args);
StringBuilder sql=new StringBuilder();int index=0;boolean quoted=false;
for(int i=0;i<bound.getSql().length();i++){char ch=bound.getSql().charAt(i);if(ch=='\'')quoted=!quoted;
if(ch!='?'||quoted){sql.append(ch);continue;}String key=bound.getParameterMappings().get(index++).getProperty();Object v=meta.getValue(key);
if(v==null)sql.append("NULL");else if(v instanceof Boolean b)sql.append(b?1:0);else if(v instanceof Number)sql.append(v);else sql.append("'").append(v.toString().replace("'","''")).append("'");
}
String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,sql.toString()),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id);
}
}
@@ -127,6 +127,38 @@ class SelfopDiscoveryServiceTest {
assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus())));
assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount());
}
@Test void emptyWarehouseWaitsForAllAccountDetailsAcrossBudgetYieldAndReceivesRelocatedOrder() {
var destination=new SelfopSyncScope();destination.setId(11L);destination.setSourceAccount("demo");destination.setWarehouseCode("B");
destination.setDataFamily("OUTBOUND");destination.setScopeHash("warehouse-b-hash");destination.setScopeJson("{}");destination.setSourceTimezone("UTC");
config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse());when(scopes.selectList(any())).thenReturn(List.of(scope,destination));
directoryTwoOrders();account.setRequestBudget(3); // Two stable directory scans and only the first detail.
var previous=new SelfopOutboundOrder();previous.setWarehouseCode("A");
when(orders.selectOne(any())).thenAnswer(inv->{QueryWrapper<SelfopOutboundOrder> query=inv.getArgument(0);query.getSqlSegment();
return query.getParamNameValuePairs().values().contains("two")?previous:null;});
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"))).thenReturn(new SelfopPayloads.Order("demo","customer","B","two",null,
100,null,"2026-10-11 12:00:00",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))));
List<String> readiness=new ArrayList<>();doAnswer(inv->{SelfopSyncTask child=inv.getArgument(0);if("WAREHOUSE_SYNC".equals(child.getTaskRole()))readiness.add(child.getWarehouseCode()+":"+child.getStatus());return 1;}).when(tasks).updateById(any(SelfopSyncTask.class));
service.collect(task,run,lease);
var warehouseA=children.get("route:1:A");var warehouseB=children.get("route:1:B");
assertEquals("WAITING_SOURCE",warehouseA.getStatus());assertEquals("WAITING_SOURCE",warehouseB.getStatus());
assertTrue(read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().isEmpty());
var partial=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
assertTrue(partial.isDirectoryComplete());assertEquals(1,partial.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count());
assertTrue(readiness.stream().noneMatch(value->value.endsWith(":READY")));verify(taskService).yield(1L,20L,lease);
verify(client,never()).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"));
account.setRequestBudget(10);service.collect(task,run,lease);
assertEquals("READY",warehouseA.getStatus());assertEquals("READY",warehouseB.getStatus());
var destinationManifest=read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class);
String movedKey=hash(List.of("demo","customer","two"));assertEquals(Set.of(movedKey),destinationManifest.getObjects().keySet());
var moved=destinationManifest.getObjects().get(movedKey);assertNotNull(moved.getEffectiveStageId());assertEquals("B",moved.getWarehouseCode());
assertEquals("B",read(stored.get(moved.getEffectiveStageId()).getPayloadJson(),SelfopPayloads.Order.class).warehouseCode());
assertTrue(read(warehouseA.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().containsKey(movedKey),"Original warehouse must retain the known-order correction reference");
assertEquals("ROUTED",run.getStatus());verify(client,times(2)).discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2));
verify(client,times(1)).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("one"));
verify(client,times(1)).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"));
}
@Test void eachActualDetailGetsIndependentObservationNotTaskSequence() {
directoryTwoOrders(); service.collect(task, run, lease);
assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList()));