feat(selfop): support complete inventory sales windows

This commit is contained in:
2026-10-13 12:35:39 +08:00
parent 39f914b3b5
commit 496fc11b0a
16 changed files with 269 additions and 72 deletions
@@ -78,10 +78,12 @@ class PlatformAnalyticsControllerTest {
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
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());
.param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")
.param("salesWindowDays","10").param("salesWindowEndDate","2026-10-12")).andExpect(status().isOk());
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class);
verify(service).inventoryCoverage(q.capture(),options.capture());assertEquals("C",q.getValue().customerCode());
assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold());
assertEquals(10,options.getValue().salesWindowDays());assertEquals(LocalDate.of(2026,10,12),options.getValue().salesWindowEndDate());assertNull(q.getValue().queryAsOf());
verifyNoInteractions(collection);
}
@Test void dashboardUsesIndependentReadGuardAndRejectsArbitraryTopN()throws Exception{
@@ -29,7 +29,7 @@ class AnalyticsQueryServiceTest {
when(mapper.count(any())).thenReturn(1L);
when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40)))));
when(mapper.inventoryCount(any(),any())).thenReturn(1L);
doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).complete30()?"150":null))).when(mapper).inventoryPage(any(),any());
doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).completeWindow()?"150":null))).when(mapper).inventoryPage(any(),any());
when(mapper.rankingStats(any())).thenReturn(List.of(new AnalyticsReadMapper.RankingStat(1L,null,0L)));
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40))));
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35))));
@@ -123,7 +123,7 @@ class AnalyticsQueryServiceTest {
Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai");
for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){
when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose(),r.warehouseDailyReady()));
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d());
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).windowOutboundQuantity());
}
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any());
assertTrue(cap.getAllValues().stream().noneMatch(AnalyticsSqlRequest::complete30));
@@ -190,6 +190,18 @@ class AnalyticsQueryServiceTest {
assertThrows(BizException.class,()->service.slowSkus(chart("TODAY",null,null,null,null)));
assertThrows(BizException.class,()->service.hotSkus(chart("LAST_3_DAYS",null,null,null,null)));
}
@Test void inventoryTenDayContextAndSqlWindowAreIndependentOfThirtyDayEligibility(){
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,10,D.minusDays(1));
var response=service.inventoryCoverage(metrics(null),o);
assertEquals(10,response.context().calculationWindowDays());assertEquals(D.minusDays(10),response.context().rangeStartDate());
assertEquals(D,response.context().rangeEndDateExclusive());assertEquals(NOW,response.context().queryAsOf());
assertEquals(n("20.0"),response.records().get(0).inventoryCoverageDays());
assertEquals(0,n("15").compareTo(response.records().get(0).averageDailyOutboundQuantity()));
assertEquals(n("150"),response.records().get(0).windowOutboundQuantity());
var captor=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).inventoryPage(captor.capture(),eq(o));
assertEquals(D.minusDays(10),captor.getValue().from());assertEquals(D,captor.getValue().until());assertTrue(captor.getValue().completeWindow());
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,10,D)));
}
@Test void unknownInventoryDoesNotReturnZeroCoverage(){
doReturn(List.of(inventory(true,null,true,time(D.minusDays(40)),"150"))).when(mapper).inventoryPage(any(),any());
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
@@ -201,7 +213,7 @@ class AnalyticsQueryServiceTest {
assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("OBSERVATION_INSUFFICIENT",unknown.status());
doReturn(List.of(inventory(true,"300",true,time(D.minusDays(40)),"0"))).when(mapper).inventoryPage(any(),any());
var zero=service.inventoryCoverage(metrics(null),options()).records().get(0);
assertEquals("NO_OUTBOUND_30_DAYS",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
}
@Test void rebuildBarrierKeepsVersionButDisablesTrustedMetrics(){
when(mapper.applyBarriers("demo","W")).thenReturn(1L); var result=service.inventoryCoverage(metrics(null),options());
@@ -247,15 +259,15 @@ 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,null,null,null,null)));
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null,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,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)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null,null,null,null,null,null,null)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1,null,null,null,null,null,null)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null,null,null)));
verifyNoInteractions(mapper);
}
@Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD");
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD",null,null);
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);
@@ -264,7 +276,7 @@ class AnalyticsQueryServiceTest {
// 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 AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,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,
@@ -104,7 +104,7 @@ class AnalyticsReadMapperTest {
}
}
@Test void stockFirstHistoryLookupRetainsCorrelatedScopeAndReverseIndexOrder(){
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null);
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null,null,null);
String sql=bound(c,"inventoryPage",Map.of("p",p,"o",o)).getSql();
assertFalse(sql.contains("last_sales AS"));assertFalse(sql.contains("JOIN last_sales"));
assertTrue(sql.contains("d.customer_code=k.customer_code AND d.sku_code=k.sku_code"));
@@ -114,7 +114,7 @@ class AnalyticsReadMapperTest {
immutableOnly(sql);
}
@Test void inventoryHistoryOnlyEnrichesQualifiedPageAndNeverCountsOrOwnerTotals(){
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,null,null);
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,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"));
@@ -126,13 +126,13 @@ class AnalyticsReadMapperTest {
}
@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))){
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"DAYS_0_30","COVERAGE_DISTRIBUTION_V1",null,null,null,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,null,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();
String count=bound(c,"inventoryCount",Map.of("p",p,"o",new AnalyticsInventoryOptions(null,attention,null,null,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"));
}
@@ -234,7 +234,7 @@ public class AnalyticsSqlSemanticTest {
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);
var o=new AnalyticsInventoryOptions(null,attention,null,null,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());
}
@@ -265,7 +265,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,null,null,null,null);
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300,null,null,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"));
@@ -274,7 +274,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,null,null,null,null));
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,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"));
}
@@ -283,17 +283,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,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());
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,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,null,null)));
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null,null,null,null,null,null,null)));
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,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,null,null,null,null));
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,null,null));
assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
}
@Test void yearlySparseSummaryPreservesOwnerIdentityAndCompletePartialUnknownSpanCounts() throws Exception {
@@ -368,7 +368,7 @@ public class AnalyticsSqlSemanticTest {
daily(99,"W","C","A",D.minusDays(1),"9000");daily(11,"OTHER","C","A",D.minusDays(1),"8000");
profile(22,"C","OTHER-WAREHOUSE","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS");
try(var statement=schema.createStatement()){statement.execute("UPDATE ll_selfop_sku_profile SET warehouse_code='OTHER' WHERE sku_code='OTHER-WAREHOUSE'");}
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null);
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null);
var p=request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),0,2);
assertEquals(6L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
var first=inventoryRows(p,options);assertEquals(List.of("B/A","C/A"),first.stream().map(r->r.customerCode()+"/"+r.skuCode()).toList());
@@ -383,11 +383,11 @@ public class AnalyticsSqlSemanticTest {
assertTrue(inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),6,2),options).isEmpty());
assertEquals(List.of("C"),inventoryRows(request("INVENTORY",D.minusDays(30),D,"C","A",utc(D,8),0,20),options).stream().map(AnalyticsReadRows.Inventory::customerCode).toList());
assertEquals(2,inventoryRows(request("INVENTORY",D.minusDays(30),D,null,"A",utc(D,8),0,20),options).size());
var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null);
var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null,null,null);
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",bucket)).contains("stock_paged"));
assertEquals(List.of("B/A","C/A"),inventoryRows(p,bucket).stream().map(r->r.customerCode()+"/"+r.skuCode()).toList());
for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_30D_ASC"))
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null))).contains("stock_paged"));
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged"));
}
@Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception {
for(String sku:List.of("HISTORY","NONE","ANCHOR")){
@@ -409,14 +409,14 @@ public class AnalyticsSqlSemanticTest {
historyRow(11,"demo","W","C","NONE",D,"0","PCS");
historyRow(11,"demo","W","C","ANCHOR",D,"1","PCS");
var p=request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),0,20);
var options=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null);
var options=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null,null,null);
var rows=inventoryRows(p,options);assertEquals(4,rows.size());
for(var row:rows){
LocalDate expected=row.skuCode().equals("ANCHOR")?D:row.skuCode().equals("NONE")?null:row.customerCode().equals("B")?D.minusDays(3):D.minusDays(40);
assertEquals(expected,row.lastOutbound(),row.customerCode()+"/"+row.skuCode());
}
// The pre-existing grouped path remains an independent semantic baseline.
var legacy=inventoryRows(p,new AnalyticsInventoryOptions(null,"ALL","PHYSICAL_DESC",null,null,null,null,null));
var legacy=inventoryRows(p,new AnalyticsInventoryOptions(null,"ALL","PHYSICAL_DESC",null,null,null,null,null,null,null));
assertEquals(legacy,rows);
var selected=inventoryRows(request("INVENTORY",D.minusDays(30),D,"C","HISTORY",utc(D,8),0,20),options);
assertEquals(1,selected.size());assertEquals(D.minusDays(40),selected.get(0).lastOutbound());
@@ -430,7 +430,7 @@ public class AnalyticsSqlSemanticTest {
}
@Test void stockFirstDoesNotTurnUnavailableSalesOrExpiredInventoryIntoKnownValues() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");stock("C","SKU",0,"10");daily(11,"W","C","SKU",D.minusDays(1),"3");
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null);
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null);
var stale=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,
utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY");
var row=inventoryRows(stale,options).get(0);assertNull(row.physicalQuantity());assertNull(row.availableQuantity());assertDecimal("3",row.ownerOutbound());assertNull(row.coverageDays());
@@ -439,16 +439,44 @@ public class AnalyticsSqlSemanticTest {
var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY");
assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue());
}
@Test void tenDayInventoryUsesItsOwnCompleteCoverageAndUnroundedThresholdWithoutLeakingScope()throws Exception {
for(String sku:List.of("TEN","ZERO","YOUNG")) {
profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays("YOUNG".equals(sku)?5:20),0),"PCS");
stock("C",sku,0,"TEN".equals(sku)?"6.008":"4");
}
daily(11,"W","C","TEN",D.minusDays(10),"1");daily(11,"W","C","TEN",D.minusDays(1),"1");
daily(11,"W","C","TEN",D.minusDays(11),"900");daily(11,"W","C","TEN",D,"800");
daily(99,"W","C","TEN",D.minusDays(1),"700");daily(11,"OTHER","C","TEN",D.minusDays(1),"600");
daily(11,"W","OTHER","TEN",D.minusDays(1),"500");
var p=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(10),D,
utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY");
var o=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1));
var rows=inventoryRows(p,o);var ten=rows.stream().filter(r->r.skuCode().equals("TEN")).findFirst().orElseThrow();
assertDecimal("2",ten.outboundQuantity30d());assertDecimal("30.04",ten.coverageDays());assertEquals(1,ten.aboveThreshold());
var stockFirst=new AnalyticsInventoryOptions(null,null,"AVAILABLE_DESC",30,null,null,null,null,10,D.minusDays(1));
var first=inventoryRows(p,stockFirst).get(0);assertEquals("TEN",first.skuCode());assertDecimal("2",first.outboundQuantity30d());assertDecimal("30.04",first.coverageDays());
var second=inventoryRows(request("INVENTORY",D.minusDays(10),D,"C",null,utc(D,8),1,1),o);
assertEquals(1,second.size());assertNotEquals("TEN",second.get(0).skuCode());
assertTrue(inventoryRows(request("INVENTORY",D.minusDays(10),D,"C",null,utc(D,8),3,1),o).isEmpty());
var zero=rows.stream().filter(r->r.skuCode().equals("ZERO")).findFirst().orElseThrow();assertDecimal("0",zero.outboundQuantity30d());assertNull(zero.coverageDays());
assertNull(rows.stream().filter(r->r.skuCode().equals("YOUNG")).findFirst().orElseThrow().outboundQuantity30d());
var filtered=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1));
assertEquals(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue());
assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
var partial=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(9),D,
utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY");
assertTrue(inventoryRows(partial,o).stream().allMatch(r->r.outboundQuantity30d()==null&&r.coverageDays()==null));
}
@SuppressWarnings("unchecked") private List<AnalyticsReadRows.Inventory> inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",p,o);
}
@SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null));
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,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,null,null,null,null));
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,null,null));
}
@SuppressWarnings("unchecked")
private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
@@ -25,20 +25,20 @@ class DashboardPolicyTest {
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");
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",null,null);
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)));
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,new AnalyticsInventoryOptions(null,null,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)));
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null,null,null),
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null,null,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());
}
@@ -19,7 +19,7 @@ class DashboardQueryServiceTest {
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,salesRun(true),null,true,true,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).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)));
@@ -42,7 +42,7 @@ class DashboardQueryServiceTest {
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
var range=SalesRangePolicy.resolve("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
for(Boolean ready:Arrays.asList(false,null)){
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
var error=assertThrows(com.baiyue.common.core.exception.BizException.class,()->service.dashboard(q));
assertEquals("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询",error.getMessage());
}
@@ -51,7 +51,7 @@ class DashboardQueryServiceTest {
when(mapper.report(any(),any())).thenReturn(List.of(Map.of("total_skus",0L,"hot_eligible",0L,"hot_unknown",0L)));
for(boolean salesPresent:List.of(true,false)){
var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot);
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD"));
var result=service.dashboard(q);
@@ -115,7 +115,7 @@ class DashboardSqlSemanticTest {
for(String bucket:CoverageDistributionPolicy.BUCKETS){
long n=count(stats,"bucket_"+bucket.toLowerCase(Locale.ROOT));assertTrue(n>0,bucket+" must be populated");sum+=n;
assertEquals(expected.get(bucket).size(),n,bucket);
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null);
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null);
assertEquals(n,((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",o)).get(0)).longValue(),bucket);
var all=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",request(),"o",o)).stream().map(AnalyticsReadRows.Inventory.class::cast).map(row->row.customerCode()+"/"+row.skuCode()).toList();
assertEquals(dashboardIdentities.get(bucket),new HashSet<>(all),bucket+" drilldown identities");assertEquals(n,all.size());
@@ -129,7 +129,7 @@ class DashboardSqlSemanticTest {
for(String key:seen)assertTrue(union.add(key),"identity belongs to multiple buckets: "+key);
}
assertEquals(count(stats,"candidate_skus"),sum);assertEquals(sum,union.size());assertFalse(union.contains("C/ZERO"));
var finiteSelection=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null);
var finiteSelection=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null,null,null);
assertEquals(13,count(stats,"long_eligible"));
assertEquals(count(stats,"long_eligible"),((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",finiteSelection)).get(0)).longValue());
assertEquals(6,count(stats,"no_outbound_eligible"));
@@ -226,7 +226,7 @@ class DashboardSqlSemanticTest {
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 AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY");}
private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return 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(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind());}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);}
@SuppressWarnings("unchecked") private List<Map<String,Object>> report(){
return ((List<Map<String,Object>>)(List<?>)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{
Map<String,Object> normalized=new HashMap<>();row.forEach((key,value)->normalized.put(key.toLowerCase(Locale.ROOT),value));return normalized;
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
class InventoryWindowPolicyTest {
private static final LocalDate DAY=LocalDate.of(2026,10,13);
private static AnalyticsInventoryOptions options(Integer days,LocalDate end) {
return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,days,end);
}
@Test void explicitCompleteWindowStaysFixedAcrossMidnightWithoutChangingQueryClock() {
var o=options(10,DAY.minusDays(1));
for(LocalDate anchor:new LocalDate[]{DAY,DAY.plusDays(1)}) {
var range=InventoryWindowPolicy.range(o,anchor);
assertEquals(LocalDate.of(2026,10,3),range.startDate());assertEquals(DAY,range.endDateExclusive());
assertEquals(10,range.dayCount());assertEquals(anchor,range.anchorDate());
}
assertEquals(DAY.minusDays(30),InventoryWindowPolicy.range(options(null,null),DAY).startDate());
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY),DAY));
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY.plusDays(1)),DAY));
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(7,null),DAY));
}
@Test void tenDayAndHistoricalWindowsCannotMasqueradeAsThirtyDayDrilldowns() {
for(var o:new AnalyticsInventoryOptions[]{
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,null,"OUTBOUND_30D_ASC",null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,10,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,DAY.minusDays(2))})
assertThrows(BizException.class,()->InventoryWindowPolicy.range(o,DAY));
assertThrows(BizException.class,()->options(10,null).validate("OVERVIEW",null));
}
@Test void tenDayFormulaKeepsMissingAndZeroSalesDistinctAndAvoidsIntermediateRounding() {
var valid=InventoryWindowPolicy.calculate("VALID",new BigDecimal("120"),new BigDecimal("30"),true,true,true,true,10);
assertEquals(0,new BigDecimal("3").compareTo(valid.average()));assertEquals(new BigDecimal("40.0"),valid.days());
var partial=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ONE,true,false,true,true,10);
assertNull(partial.average());assertNull(partial.days());assertEquals("COVERAGE_INSUFFICIENT",partial.status());
var zero=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ZERO,true,true,true,true,10);
assertEquals(0,zero.average().signum());assertNull(zero.days());assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,null,true,true,true,true,10).days());
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,false,true,10).days());
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,true,false,10).days());
assertNull(InventoryWindowPolicy.calculate("STALE",BigDecimal.ZERO,BigDecimal.TEN,true,true,true,true,10).days());
assertEquals(new BigDecimal("0.0"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ZERO,null,false,false,false,false,10).days());
assertEquals(new BigDecimal("3.3"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ONE,new BigDecimal("3"),true,true,true,true,10).days());
}
@Test void inventoryResponseHasOneNeutralWindowQuantityWithoutThirtyDayAliases() {
var names=java.util.Arrays.stream(AnalyticsResponses.InventoryMetric.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList();
assertTrue(names.contains("windowOutboundQuantity"));assertFalse(names.contains("outboundQuantity30d"));
assertFalse(names.contains("outboundQuantity"));assertFalse(names.contains("todayObservedQuantity"));
}
}