feat(self-operated): support selected-day analytics and global quantity sorting

This commit is contained in:
2026-10-14 12:44:15 +08:00
parent 287edcf32b
commit bc9c78122b
31 changed files with 329 additions and 144 deletions
@@ -41,9 +41,9 @@ class PlatformAnalyticsControllerTest {
@Test void allElevenReadEndpointsShareTheIndependentGuard(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
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.salesSummary(user,null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);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);
verify(service).salesSummary(null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);verify(service).dailySales(null);verify(service).hotSkus(null);
verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null);
verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null);
assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformAnalyticsController.class.getAnnotation(PreAuthorize.class).value());
@@ -62,10 +62,19 @@ class PlatformAnalyticsControllerTest {
.param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22")
.param("salesScopeHash","a".repeat(64)).param("inventoryScopeHash","b".repeat(64))
.param("queryAsOf","2026-10-12T04:00:00Z")).andExpect(status().isOk());
var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture());
var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture(),eq(com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC));
assertEquals(LocalDate.of(2026,10,1),cap.getValue().startDate());assertEquals(Instant.parse("2026-10-12T04:00:00Z"),cap.getValue().queryAsOf());
assertEquals(11L,cap.getValue().salesRunId());assertEquals(2,cap.getValue().current());
}
@Test void salesSortBindsAscendingAndRejectsUnknownValues() throws Exception {
var service=mock(AnalyticsQueryService.class);
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,mock(CollectionQueryService.class),mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary").param("salesSort","QUANTITY_ASC")).andExpect(status().isOk());
verify(service).salesSummary(any(),eq(com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_ASC));
clearInvocations(service);
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary").param("salesSort","invalid")).andExpect(status().isBadRequest());
verifyNoInteractions(service);
}
@Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
@@ -93,6 +93,7 @@ class AnalyticsQueryServiceTest {
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
when(mapper.count(any())).thenReturn(1L);
when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40)))));
when(mapper.salesPage(any(),any())).thenAnswer(invocation -> mapper.page(invocation.getArgument(0)));
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).completeWindow()?"150":null))).when(mapper).inventoryPage(any(),any());
when(mapper.rankingStats(any())).thenReturn(List.of(new AnalyticsReadMapper.RankingStat(1L,null,0L)));
@@ -108,9 +109,21 @@ class AnalyticsQueryServiceTest {
assertFalse(cap.getValue().completeWindow());assertEquals(D.minusDays(44),result.context().rangeStartDate());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(2),D,null,7,1,20,null,null,null,null,null).validate(false,false,true));
}
@Test void customSlowRangeUsesSelectedDaysAndPreservesIncompleteLongWindow(){
for(int days:List.of(3,45)){
clearInvocations(mapper);
var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(days),D.minusDays(1),null,null,1,20,null,null,null,null,null);
var result=service.slowSkus(q);
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture());
assertEquals(D.minusDays(days),cap.getValue().from());assertEquals(D,cap.getValue().until());
assertEquals(days,result.context().calculationWindowDays());
if(days==3)assertEquals(0,n("50").compareTo(result.records().get(0).averageDailyOutboundQuantity()));
else assertNull(result.records().get(0).averageDailyOutboundQuantity());
}
}
@Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){
when(mapper.count(any())).thenReturn(240L);
var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null));
var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);
verify(mapper).count(cap.capture()); verify(mapper).page(cap.capture());
assertEquals(cap.getAllValues().get(0),cap.getAllValues().get(1));
@@ -134,7 +147,7 @@ class AnalyticsQueryServiceTest {
assertEquals(1,response.summary().partialDayCount()); assertEquals("PCS",response.summary().quantityUnit());
verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1));
verify(mapper,never()).page(any()); verify(mapper,never()).count(any());
var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU")).records().get(0);
var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU"),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC).records().get(0);
assertEquals(response.summary().observedQuantity(),summary.observedQuantity());
assertEquals(response.summary().outboundQuantity(),summary.outboundQuantity()); assertEquals(response.summary().dataStatus(),summary.dataStatus());
}
@@ -203,9 +216,9 @@ class AnalyticsQueryServiceTest {
}
@Test void pinnedDateSurvivesMidnightAndNewPublishedPointers(){
Instant before=Instant.parse("2026-10-12T15:55:00Z"),after=before.plusSeconds(600);
var first=at(before).salesSummary(chart("TODAY",null,null,null,null));
var first=at(before).salesSummary(chart("TODAY",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,33L),scope(2,"INVENTORY",IH,44L)));
var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2));
var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
assertEquals(D,second.context().anchorDate()); assertEquals(D,second.context().rangeStartDate());
assertEquals(D.plusDays(1),second.context().rangeEndDateExclusive()); assertEquals(11L,second.context().salesRunId());
verify(mapper,never()).publishedRun(33); verify(mapper,never()).publishedRun(44);
@@ -213,9 +226,9 @@ class AnalyticsQueryServiceTest {
}
@Test void missingVersionZeroStaysMissingAfterNewPointerAppears(){
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,0L),scope(2,"INVENTORY",IH,0L)));
var first=service.salesSummary(chart("TODAY",null,null,null,null)); assertEquals(0L,first.context().salesRunId());
var first=service.salesSummary(chart("TODAY",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); assertEquals(0L,first.context().salesRunId());
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2));
var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
assertEquals(0L,second.context().inventoryRunId()); assertEquals(0L,second.context().salesRunId()); verify(mapper,never()).publishedRun(anyLong());
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture());
assertEquals(0,cap.getValue().salesRun()); assertEquals(0,cap.getValue().inventoryRun()); assertFalse(cap.getValue().salesKnown());
@@ -280,11 +293,11 @@ class AnalyticsQueryServiceTest {
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
assertNull(row.inventoryCoverageDays());assertNull(row.availableQuantity());assertEquals("UNKNOWN",row.inventoryQuality());
}
@Test void noObservationEvidenceCannotBecomeThirtyDayDenominatorAndNoSalesIsNotInfinity(){
@Test void unknownSalesStayUnknownAndConfirmedZeroNeedsNoObservationAge(){
doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any());
var unknown=service.inventoryCoverage(metrics(null),options()).records().get(0);
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());
assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("SALES_UNKNOWN",unknown.status());
doReturn(List.of(inventory(true,"300",true,null,"0"))).when(mapper).inventoryPage(any(),any());
var zero=service.inventoryCoverage(metrics(null),options()).records().get(0);
assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
}
@@ -363,6 +376,22 @@ class AnalyticsQueryServiceTest {
// Normal inventory diagnostics still return the same fixed but stale version.
assertEquals("STALE",service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1),options()).context().inventoryStatus());
}
@Test void completeInventorySalesCalculateWithoutObservationAgeAndRetainTheWarning(){
var base=inventory(true,"300",true,time(D.minusDays(10)),"92");
var partial=new Inventory(base.customerCode(),base.skuCode(),base.productName(),true,base.evidenceAtUtc(),base.physicalQuantity(),base.availableQuantity(),
base.frozenQuantity(),base.transitQuantity(),base.defectivePhysicalQuantity(),base.observedAtUtc(),n("92"),n("92"),base.orderCount(),base.lastOutbound(),false,n("92"),n("97.8"),0,0,false);
doReturn(List.of(partial)).when(mapper).inventoryPage(any(),any());
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
assertEquals(n("92"),row.observedWindowOutboundQuantity());assertEquals(n("92"),row.windowOutboundQuantity());
assertEquals(n("3.066666666667"),row.averageDailyOutboundQuantity());assertEquals(n("97.8"),row.inventoryCoverageDays());
assertEquals("VALID",row.status());assertEquals("VALID",row.quantityStatuses().get("windowOutboundQuantity"));
assertFalse(row.observationEligible());assertTrue(row.reasonCodes().contains("OBSERVATION_INSUFFICIENT"));assertFalse(row.attentionReasons().contains("UNASSESSABLE"));
var json=new ObjectMapper().findAndRegisterModules().valueToTree(row);
assertEquals(92,json.get("observedWindowOutboundQuantity").intValue());
// Unknown evidence must stay unknown; it is never converted into a collected zero.
doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any());
assertNull(service.inventoryCoverage(metrics(null),options()).records().get(0).observedWindowOutboundQuantity());
}
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);
@@ -23,6 +23,15 @@ class AnalyticsReadMapperTest {
assertFalse(sql.contains("ll_selfop_outbound_order")); assertFalse(sql.contains("ll_selfop_outbound_item")); assertFalse(sql.contains("ll_selfop_sync_stage"));
assertFalse(sql.contains("${")); assertFalse(sql.contains("Exact-SKU")); assertFalse(sql.contains("OWNER"));
}
@Test void quantityOrderingIsAppliedBeforePaginationAndKeepsUnknownLast(){
for(var sort:SalesSort.values()){
var sql=bound(config(),"salesPage",Map.of("p",request("SUMMARY"),"salesSort",sort)).getSql();
String direction=sort==SalesSort.QUANTITY_ASC?"ASC":"DESC";
assertTrue(sql.contains("sort_observed_quantity IS NULL,sort_observed_quantity "+direction));
assertTrue(sql.contains("ORDER BY observed_quantity IS NULL,observed_quantity "+direction));
assertTrue(sql.indexOf("sort_observed_quantity "+direction)<sql.indexOf("LIMIT"));
}
}
@Test void allMapperStatementsAreSelectAndProvidersBindTheNamedRecord(){
var c=config(); assertNotNull(AnalyticsReadMapper.class.getAnnotation(Mapper.class));
for(String method:List.of("scopes","publishedRun","page","count","rankingStats","profile","daily","applyBarriers")){
@@ -84,6 +93,7 @@ class AnalyticsReadMapperTest {
assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?"));
assertTrue(sql.contains("AND st.product_quality=0"));
if("HOT".equals(kind))assertTrue(sql.contains("business_date>=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date<GREATEST(CAST(? AS DATE),TIMESTAMPADD(DAY,1,CAST(? AS DATE)))"));
else if("SLOW".equals(kind))assertTrue(sql.contains("business_date>=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
else 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"));
@@ -280,13 +280,14 @@ public class AnalyticsSqlSemanticTest {
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"));
}
@Test void ownerSalesCompleteDoesNotRequireEachSkuObservationAndRiskUnknownIsRetained() throws Exception {
@Test void completeWindowSalesAndCoverageDoNotRequireSkuObservationAge() throws Exception {
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());
var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertDecimal("5",row.outboundQuantity30d());assertEquals(0,row.noOutbound());
assertFalse(row.observationEligible());assertDecimal("120",row.coverageDays());assertFalse(row.unassessable());
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"));
assertEquals(0L,((Number)totals.get("no_outbound_unknown")).longValue());
}
@Test void ownersAreCaseSensitiveDistinctAndPrefixWildcardsAreLiteral() throws Exception {
for(String owner:List.of("A%","A_test","ABC","abc")){
@@ -327,6 +328,19 @@ public class AnalyticsSqlSemanticTest {
assertEquals(0,row.partialDayCount());assertEquals(366,row.unknownDayCount());
}
}
@Test void slowCustomRangeCountsOnlySelectedDatesIncludingHistoryBeyondThirtyDays() throws Exception {
profile(11,"C","CUSTOM","VALID_OUTBOUND",utc(D.minusDays(60),0),"PCS");
profile(22,"C","CUSTOM","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");
stock("C","CUSTOM",0,"100");
daily(11,"W","C","CUSTOM",D.minusDays(40),"30");
daily(11,"W","C","CUSTOM",D.minusDays(10),"20");
daily(11,"W","C","CUSTOM",D.minusDays(1),"10");
var shortRows=page(request("SLOW",D.minusDays(3),D,null,null,utc(D,0),0,20));
var longRows=page(request("SLOW",D.minusDays(45),D,null,null,utc(D,0),0,20));
assertEquals(1,shortRows.size());assertEquals(1,longRows.size());
assertEquals(0,new BigDecimal("10").compareTo(shortRows.get(0).windowQuantity()));
assertEquals(0,new BigDecimal("60").compareTo(longRows.get(0).windowQuantity()));
}
@Test void rankingStatisticsPreserveFullEligibilityTopCapExclusionsAndSharedSkuOwners() throws Exception {
for(int i=0;i<110;i++){
String sku=String.format("SKU-%03d",i);profile(11,"C",sku,"VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
@@ -386,7 +400,7 @@ public class AnalyticsSqlSemanticTest {
var second=inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),2,2),options);
assertEquals(List.of("B","NO-SALES"),second.stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
assertEquals(D.minusDays(40),second.get(0).lastOutbound());assertDecimal("0",second.get(0).outboundQuantity30d());
assertNull(second.get(1).outboundQuantity30d());assertTrue(second.get(1).unassessable());
assertDecimal("0",second.get(1).outboundQuantity30d());assertFalse(second.get(1).unassessable());assertFalse(second.get(1).observationEligible());
var third=inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),4,2),options);
assertEquals(List.of("UNIT","UNKNOWN"),third.stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
assertTrue(third.stream().allMatch(r->r.availableQuantity()==null));assertNull(third.get(0).physicalQuantity());assertDecimal("100",third.get(1).physicalQuantity());
@@ -399,6 +413,24 @@ public class AnalyticsSqlSemanticTest {
for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_ASC"))
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged"));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"AVAILABLE","PHYSICAL","FROZEN","DEFECTIVE","TRANSIT","OUTBOUND","AVERAGE_DAILY","COVERAGE_DAYS"})
void allInventoryNumericSortsKeepUnknownLastAndSortBeforePagination(String metric) throws Exception {
for(String sku:List.of("LOW","LOW-TIE","HIGH","UNKNOWN")) {
String unit=sku.equals("UNKNOWN")?"BOX":"PCS";
profile(11,"C",sku,"VALID_OUTBOUND",utc(D.minusDays(40),0),unit);
profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(40),0),unit);
boolean high=sku.equals("HIGH");
stockExact(sku,0,high?"1000":"100",high?"1000":"100",high?"9":"1",high?"90":"10","VALID",null);
stockExact(sku,1,high?"90":"10","0","0","0","VALID",null);
daily(11,"W","C",sku,D.minusDays(1),high?"5":"1");
}
for(String direction:List.of("ASC","DESC")) {
var expected=direction.equals("ASC")?List.of("LOW","LOW-TIE","HIGH","UNKNOWN"):List.of("HIGH","LOW","LOW-TIE","UNKNOWN");
assertEquals(expected,inventory(null,null,metric+"_"+direction,0,20).stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
assertEquals(expected.get(1),inventory(null,null,metric+"_"+direction,1,1).get(0).skuCode());
}
}
@Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception {
for(String sku:List.of("HISTORY","NONE","ANCHOR")){
profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS");stock("C",sku,0,"10");
@@ -469,7 +501,8 @@ public class AnalyticsSqlSemanticTest {
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 young=rows.stream().filter(r->r.skuCode().equals("YOUNG")).findFirst().orElseThrow();
assertDecimal("0",young.outboundQuantity30d());assertFalse(young.observationEligible());assertEquals(1,young.noOutbound());assertNull(young.coverageDays());
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());
@@ -50,13 +50,27 @@ class DashboardPolicyTest {
assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE"));
}
}
@Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){
@Test void historicalCoverageStillRequiresKnownSourceAndValidQuantity(){
LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L));
assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows,java.util.List.of()).get(0).dataStatus());
var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L)),java.util.List.of()).get(0);
assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());assertTrue(invalid.reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows,java.util.List.of()).get(0);
assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity());
}
@Test void pendingRebuildPreservesFrozenHistoricalCoverageWithoutFillingGapsOrToday(){
LocalDate day=DAY.minusDays(3);var c=publishedDayContext("REBUILD_REQUIRED",true);
for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))){
var rows=amount.signum()==0?List.<DashboardReadMapper.Day>of():List.of(new DashboardReadMapper.Day(day,amount,0L));
var points=DashboardSalesPolicy.points(c,true,day,DAY.plusDays(1),rows,List.of());
assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(amount,points.get(0).outboundQuantity());
assertTrue(points.get(0).reasonCodes().contains("SALES_REBUILD_REQUIRED"));
assertFalse(points.get(0).reasonCodes().contains("SALES_DAY_INCOMPLETE"));
assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN"),points.stream().map(p->p.dataStatus()).toList());
assertTrue(points.get(3).reasonCodes().contains("TODAY_INCOMPLETE"));
assertNull(SalesBucketPolicy.aggregate(points).outboundQuantity());
}
var invalid=DashboardSalesPolicy.points(c,true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,BigDecimal.ONE,1L)),List.of()).get(0);
assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());
assertEquals("UNKNOWN",DashboardSalesPolicy.points(c,false,day,day.plusDays(1),List.of(),List.of()).get(0).dataStatus());
}
private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){
LocalDate day=DAY.minusDays(3);var zone=ZoneId.of("Asia/Shanghai");
@@ -184,6 +184,22 @@ class DashboardSqlSemanticTest {
assertEquals(6,count(stats,"no_outbound_eligible"));
assertTrue(ranks().stream().anyMatch(row->row.rankCode().equals("NO_OUTBOUND")&&row.skuCode().equals("FROZEN-0")));
}
@Test void newSkuWindowMetricsMatchDashboardAndDrilldownWithoutObservationAge()throws Exception{
fixture("NEW-SOLD","30","30","15");fixture("NEW-UNSOLD","40","40",null);
try(var statement=connection.createStatement()) {
statement.execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=NULL,evidence_type='NONE'");
}
var p=windowRequest(D.minusDays(15),D,true,"DASHBOARD");
var report=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()));
Map<String,Object> values=new HashMap<>();((Map<?,?>)report.get(0)).forEach((k,v)->values.put(k.toString().toLowerCase(Locale.ROOT),v));
assertEquals(1,count(values,"long_eligible"));assertEquals(1,count(values,"no_outbound_eligible"));assertEquals(0,count(values,"bucket_unknown"));
var rows=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",p,"o",options())).stream().map(AnalyticsReadRows.Inventory.class::cast).toList();
assertEquals(2,rows.size());assertTrue(rows.stream().noneMatch(AnalyticsReadRows.Inventory::observationEligible));
var sold=rows.stream().filter(row->row.skuCode().equals("NEW-SOLD")).findFirst().orElseThrow();
assertEquals(0,new BigDecimal("15").compareTo(sold.outboundQuantity30d()));assertEquals(0,new BigDecimal("30").compareTo(sold.coverageDays()));
var zero=rows.stream().filter(row->row.skuCode().equals("NEW-UNSOLD")).findFirst().orElseThrow();
assertEquals(0,zero.outboundQuantity30d().signum());assertNull(zero.coverageDays());assertEquals(1,zero.noOutbound());
}
@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_in_window"));
@@ -46,6 +46,16 @@ class InventoryTurnoverContextTest {
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
}
@Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() {
var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null);
var first=contexts.resolve(q).response();
assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size());
assertTrue(first.snapshotContext().length()<2048);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext());
assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response());
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(next));
}
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
String token=contexts.resolve(request(null)).response().snapshotContext();
var evidence=json.readTree(Base64.getUrlDecoder().decode(token));
@@ -19,7 +19,7 @@ class InventoryTurnoverRequestTest {
assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview());
}
@Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() {
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",10),
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367),
Map.<String,Object>of("groupBy","OWNER","sku","S"),Map.<String,Object>of("groupBy","OWNER","sortBy","COVERAGE_DESC"),
Map.<String,Object>of("sortBy","NO_OUTBOUND_PHYSICAL_DESC"),Map.<String,Object>of("current",2),
Map.<String,Object>of("size",101),Map.<String,Object>of("coverageDaysThreshold",0),Map.<String,Object>of("focus","COVERAGE_GT"),
@@ -31,6 +31,27 @@ class InventoryTurnoverSqlTest {
for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i<samples?100L+i:null));}
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days));
}
@Test void selectedCustomWindowsUseEveryDayIncludingBeyondThirtyAndKeepSampleGaps() throws Exception {
seed("A","SKU",1);
execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'");
for(int n:List.of(3,45,366)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
var dates=new ArrayList<InventoryTurnoverSql.Day>();
int horizon=Math.max(30,n);
for(int i=0;i<horizon;i++) {
var date=END.minusDays(horizon-1-i);
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,date.equals(END)?null:100L));
}
var input=new InventoryTurnoverSql.Input(q,11,22,true,true,dates);
var row=query("page",input).get(0);
decimal(Integer.toString(Math.min(30,n)*10),row.get("w"+n+"_outbound"));
decimal(Integer.toString(n-1),row.get("sample_count"));
assertEquals(END.toString(),row.get("missing_dates"));
assertNull(row.get("turnover"));
var chart=query("chartWindows",input).get(0);
decimal(Integer.toString(Math.min(30,n)*10),chart.get("w"+n+"_outbound"));
}
}
@Test void threeWindowsAndDailyQualitiesAreNotMultiplied() throws Exception {
seed("A","SKU",30);
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
@@ -110,7 +131,7 @@ class InventoryTurnoverSqlTest {
execute("INSERT INTO ll_selfop_sku_daily_sales VALUES (99,'demo','W','A','SKU',DATE '"+END+"',9999,'PCS')");
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
var chart=query("chartWindows",input(q,18,0,true)).get(0);
var windows=InventoryTurnoverPolicy.windows(chart,END.plusDays(1));
var windows=InventoryTurnoverPolicy.windows(chart,END.plusDays(1),List.of(7,15,30));
for(int i=0;i<2;i++) {
var window=windows.get(i);
assertEquals("COMPLETE",window.dataStatus());assertEquals(window.days(),window.completeDayCount());
@@ -190,7 +211,7 @@ class InventoryTurnoverSqlTest {
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates));
var row=query("page",p).get(0);
decimal(onlyLast?"536870912":"536870911",row.get("sample_mask"));
assertNotNull(row.get("sample_mask"));
decimal(String.valueOf(onlyLast?1:n-1),row.get("sample_count"));
assertEquals(String.join(",",missing),row.get("missing_dates"));
assertNull(row.get("average_physical"));assertNull(row.get("turnover"));
@@ -514,7 +535,7 @@ class InventoryTurnoverSqlTest {
// MySQL SIGNED is a signed 64-bit integer; H2's alias is only 32-bit.
sql=sql.replace(" AS SIGNED)"," AS BIGINT)");
// MySQL's unbounded CHAR cast preserves the date; H2 defaults to CHAR(1).
sql=sql.replace("CAST(business_date AS CHAR)","CAST(business_date AS VARCHAR(10))");
sql=sql.replace("CAST(business_date AS CHAR)","CAST(business_date AS VARCHAR(10))").replace("CAST(d.business_date AS CHAR)","CAST(d.business_date AS VARCHAR(10))");
// H2 inlines repeatedly referenced CTEs exponentially. Materialize each unchanged
// production relation in this local fixture; MySQL execution plans remain unverified.
List<String> relations=new ArrayList<>();int cursor=5;
@@ -21,7 +21,7 @@ class InventoryWindowPolicyTest {
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));
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(367,null),DAY));
}
@Test void selectedWindowPredicatesWorkForTenDayAndHistoricalWindows() {
for(var o:new AnalyticsInventoryOptions[]{
@@ -33,18 +33,18 @@ class InventoryWindowPolicyTest {
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);
var valid=InventoryWindowPolicy.calculate("VALID",new BigDecimal("120"),new BigDecimal("30"),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);
var partial=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ONE,true,false,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);
var zero=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ZERO,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());
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,null,true,true,true,10).days());
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,false,true,true,10).days());
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,false,10).days());
assertNull(InventoryWindowPolicy.calculate("STALE",BigDecimal.ZERO,BigDecimal.TEN,true,true,true,10).days());
assertEquals(new BigDecimal("0.0"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ZERO,null,false,false,false,10).days());
assertEquals(new BigDecimal("3.3"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ONE,new BigDecimal("3"),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();