This commit is contained in:
2026-10-14 11:03:02 +08:00
parent 6193043880
commit 83dc6dea3f
26 changed files with 325 additions and 98 deletions
@@ -339,15 +339,29 @@ class AnalyticsQueryServiceTest {
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null,null,null)));
verifyNoInteractions(mapper);
}
@Test void dashboardInventoryUsesExactSelectedDatesInsteadOfDefaultThirtyDays(){
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE","DASHBOARD",null,null);
for(int days:List.of(1,7,15,45)){
clearInvocations(mapper);
var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(days),D.minusDays(1),null,null,1,20,11L,22L,SH,IH,NOW);
var response=service.inventoryCoverage(q,o);
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).inventoryPage(cap.capture(),eq(o));
assertEquals(D.minusDays(days),cap.getValue().from());assertEquals(D,cap.getValue().until());
assertEquals(D.minusDays(days),response.context().rangeStartDate());assertEquals(D,response.context().rangeEndDateExclusive());
assertEquals(days,response.context().calculationWindowDays());
assertThrows(BizException.class,()->service.inventoryCoverage(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,null)));
}
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o));
}
@Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){
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);
var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(7),D.minusDays(1),null,null,1,20,11L,22L,SH,IH,NOW.minusSeconds(60));
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());
assertEquals("STALE",service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1),options()).context().inventoryStatus());
}
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){
@@ -256,12 +256,12 @@ public class AnalyticsSqlSemanticTest {
var row=inventory(null,null,null,0,20).get(0);
assertDecimal("23",row.physicalQuantity());assertDecimal("10",row.availableQuantity());assertNull(row.frozenQuantity());
assertNull(row.defectivePhysicalQuantity());assertEquals(1,row.noOutbound());assertNull(row.coverageDays());
assertEquals(1,inventory("NO_OUTBOUND_30D",null,null,0,20).size());
assertEquals(1,inventory("NO_OUTBOUND_IN_WINDOW",null,null,0,20).size());
}
@Test void frozenDefectiveOnlyStockMatchesNoOutboundEvenWhenGoodAvailableIsZero() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
stockExact("SKU",0,"0","0","0","0","VALID",null);stockExact("SKU",1,"40","0","40","0","VALID",null);
var row=inventory("NO_OUTBOUND_30D",null,null,0,20).get(0);
var row=inventory("NO_OUTBOUND_IN_WINDOW",null,null,0,20).get(0);
assertDecimal("0",row.coverageDays());assertEquals(1,row.noOutbound());assertEquals(1,inventory("FROZEN_STOCK",null,null,0,20).size());
assertEquals(1,inventory("DEFECTIVE_STOCK",null,null,0,20).size());assertTrue(inventory("COVERAGE_GT_THRESHOLD",1,null,0,20).isEmpty());
}
@@ -396,7 +396,7 @@ public class AnalyticsSqlSemanticTest {
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"))
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"));
}
@Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception {
@@ -20,7 +20,8 @@ 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());}
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return context(coveredFrom,coveredUntil,inventory,DAY.minusDays(14),DAY.plusDays(1));}
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory,LocalDate from,LocalDate until){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,from,until,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,java.util.List.of());var summary=SalesBucketPolicy.aggregate(points);
@@ -76,7 +77,7 @@ class DashboardPolicyTest {
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,"NO_OUTBOUND_IN_WINDOW",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());
@@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DashboardQueryServiceTest {
@Test void oneContextKeepsThirtyDaySalesAndHotAvailableWhenInventoryUnavailable()throws Exception{
@Test void selectedWindowKeepsHotAvailableButDoesNotInventAverageWhenIncomplete()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);
@@ -27,11 +27,12 @@ class DashboardQueryServiceTest {
reportRow.putAll(Map.of("rank_code","HOT","customer_code","A","sku_code","X","product_name","Product","main_value",new BigDecimal("30"),"owner_outbound",new BigDecimal("30"),"order_count",1L));
when(mapper.report(any(),any())).thenReturn(List.of(reportRow));
var result=service.dashboard(q);
assertEquals(0,BigDecimal.ONE.compareTo(result.kpis().averageDailyOutboundQuantity30d().value()));
assertNull(result.kpis().averageDailyOutboundQuantity().value());
assertEquals(15,result.hotSkus().calculationWindowDays());assertEquals("PARTIAL",result.hotSkus().dataStatus());assertNull(result.hotSkus().items().get(0).averageDailyOutboundQuantity());
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());
assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(14),result.context().versionContext().rangeStartDate());
assertEquals(15,result.longCoverageSkus().calculationWindowDays());assertEquals(15,result.noOutboundSkus().calculationWindowDays());
verify(mapper).daily(any(),any(),any());verify(mapper,times(2)).report(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());
@@ -40,8 +41,8 @@ class DashboardQueryServiceTest {
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("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false,day.minusDays(15),day);
var range=SalesRangePolicy.resolve("CUSTOM",day.minusDays(15),day.minusDays(1),"DAY",now,ZoneId.of("Asia/Shanghai"),366);
for(Boolean ready:Arrays.asList(false,null)){
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));
@@ -53,11 +54,11 @@ class DashboardQueryServiceTest {
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),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,
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day.minusDays(15),day,day,day.minusDays(30),day.minusDays(30),day,
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD",java.util.List.of()));
var result=service.dashboard(q);
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity().dataStatus());}
}
}
private static AnalyticsReadRows.Run salesRun(Boolean ready){
@@ -39,6 +39,40 @@ class DashboardSqlSemanticTest {
assertTrue(bound.getSql().endsWith("ORDER BY business_date"));
}
}
@Test void selectedDatesChangeDistributionLongestAndZeroOutboundTogetherWithExactDrilldowns()throws Exception{
fixture("RECENT","90","90","30");fixture("OLDER","60","60",null);
daily("C","OLDER",D.minusDays(20),"10","PCS");
// 7 complete recent days: RECENT=21 days to sell, OLDER has zero outbound.
// 30 complete days: RECENT=90, OLDER=180. Historical 7 days: only OLDER sold.
for(var period:List.of(new LocalDate[]{D.minusDays(7),D},new LocalDate[]{D.minusDays(30),D},new LocalDate[]{D.minusDays(21),D.minusDays(14)})){
int days=(int)java.time.temporal.ChronoUnit.DAYS.between(period[0],period[1]);
boolean historical=period[1].isBefore(D);
var p=windowRequest(period[0],period[1],true,"DASHBOARD");
var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()));
var ranks=rows.stream().map(row->{Map<String,Object> normalized=new HashMap<>();((Map<?,?>)row).forEach((k,v)->normalized.put(k.toString().toLowerCase(Locale.ROOT),v));return DashboardQueryService.ranked(normalized);}).toList();
var longest=ranks.stream().filter(r->"LONG".equals(r.rankCode())).max(Comparator.comparing(DashboardReadMapper.Ranked::mainValue)).orElseThrow();
assertEquals(days==30||historical?"OLDER":"RECENT",longest.skuCode());
assertEquals(0,new BigDecimal(days==30?"180":historical?"42":"21").compareTo(longest.mainValue()));
var zero=ranks.stream().filter(r->"NO_OUTBOUND".equals(r.rankCode())).map(DashboardReadMapper.Ranked::skuCode).toList();
assertEquals(days==30?List.of():List.of(historical?"RECENT":"OLDER"),zero);
for(String bucket:CoverageDistributionPolicy.BUCKETS){
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null);
var identities=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()),false,true).stream().map(Map.class::cast)
.filter(r->bucket.equals(r.get("BUCKET_CODE"))).map(r->r.get("SKU_CODE")).collect(java.util.stream.Collectors.toSet());
var detail=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",windowRequest(period[0],period[1],true,"INVENTORY"),"o",o))
.stream().map(AnalyticsReadRows.Inventory.class::cast).map(AnalyticsReadRows.Inventory::skuCode).collect(java.util.stream.Collectors.toSet());
assertEquals(identities,detail,bucket+" "+period[0]);
if("DAYS_0_30".equals(bucket))assertEquals(days==7&&!historical?Set.of("RECENT"):Set.of(),identities);
}
}
// Incomplete selection must not turn missing days into zero outbound.
var incomplete=query(DashboardReadMapper.class,"report",Map.of("p",windowRequest(D.minusDays(7),D.plusDays(1),false,"DASHBOARD"),"o",options()));
assertTrue(incomplete.stream().map(Map.class::cast).allMatch(r->r.get("RANK_CODE")==null));
assertEquals(2L,((Number)((Map<?,?>)incomplete.get(0)).get("BUCKET_UNKNOWN")).longValue());
}
private AnalyticsSqlRequest windowRequest(LocalDate from,LocalDate until,boolean complete,String kind){var p=request();
return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),from,until,D,from,p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,complete,true,true,0,20,kind,List.of());
}
@Test void selectedRangeHotUsesObservedSalesIncludingTodayAndMatchesTop100DespiteIncompleteCoverage()throws Exception{
fixture("RECENT","50","50","3");fixture("OLD","50","50",null);
daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS");
@@ -110,7 +144,7 @@ class DashboardSqlSemanticTest {
{"NINETY-ABOVE","90.000001","DAYS_GT_90"},{"LONG-A","100","DAYS_GT_90"},{"LONG-B","100","DAYS_GT_90"}};
for(var row:finite){fixture(row[0],row[1],row[1],"30");expected.get(row[2]).add("C/"+row[0]);}
for(int i=0;i<3;i++){
fixture("NOOUT-"+i,"100","100",null);expected.get("NO_OUTBOUND_30D").add("C/NOOUT-"+i);
fixture("NOOUT-"+i,"100","100",null);expected.get("NO_OUTBOUND_IN_WINDOW").add("C/NOOUT-"+i);
fixture("FROZEN-"+i,"50","0",null);expected.get("NO_AVAILABLE").add("C/FROZEN-"+i);
fixture("UNKNOWN-"+i,null,null,null);expected.get("UNKNOWN").add("C/UNKNOWN-"+i);
}
@@ -152,7 +186,7 @@ class DashboardSqlSemanticTest {
}
@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"));
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"));
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{
@@ -63,6 +63,8 @@ class InventoryTurnoverContextTest {
var c=contexts.resolve(request(null)).response();
assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId());
assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus());
var absent=InventoryTurnoverSql.chartInput(request(null),contexts.resolve(request(null)));
assertFalse(absent.salesKnown());assertTrue(absent.days().stream().allMatch(d->d.coverage()==0));
assertEquals(c,contexts.resolve(request(c.snapshotContext())).response());
when(analytics.scopes("demo","W")).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(null)));
@@ -81,6 +83,27 @@ class InventoryTurnoverContextTest {
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
var rebuilding=contexts.resolve(request(null));
assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0));
assertTrue(rebuilding.response().analyticsContext().rebuildRequired());
assertTrue(InventoryTurnoverSql.chartInput(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==1));
}
@Test void publishedChartRetainsEighteenCoveredDaysWhileCurrentAssessmentsWaitForRebuild() {
var published=run(11,1,SH);
var eighteen=new Run(published.id(),published.taskId(),published.scopeId(),published.sourceAccount(),published.warehouseCode(),published.runRole(),
published.scopeHash(),published.scopeSnapshotJson(),published.status(),published.coverageStatus(),
END.minusDays(17).atStartOfDay().minusHours(8),published.coveredUntilUtc(),END.minusDays(17),published.completeDayUntil(),
published.fetchStartedAtUtc(),published.fetchFinishedAtUtc(),published.publishedAtUtc(),published.calculatedFactRevision(),null,null,null,"BUSINESS",true);
when(analytics.publishedRun(11L)).thenReturn(eighteen);
when(mapper.versionAt(eq(1L),eq(SH),any())).thenReturn(eighteen);
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
Map.of("section","OVERVIEW","payload","{\"sku_count\":0}")));
var result=new InventoryTurnoverQueryService(contexts,mapper,json).query(request(null));
assertTrue(result.context().analyticsContext().rebuildRequired());
verify(mapper).report(argThat(p->p.days().stream().allMatch(d->d.coverage()==0)));
verify(mapper).chartWindows(argThat(p->p.salesRun()==11L&&p.inventoryRun()==22L&&p.salesKnown()
&&p.days().stream().filter(d->d.coverage()==1).count()==18
&&p.days().subList(0,12).stream().allMatch(d->d.coverage()==0)
&&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1)));
}
@Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() {
var service=new InventoryTurnoverQueryService(contexts,mapper,json);
@@ -103,6 +103,23 @@ class InventoryTurnoverSqlTest {
assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed"));
decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count"));
}
@Test void eighteenPublishedDaysGiveSevenAndFifteenDayMeansWithoutInventingThirtyDays() throws Exception {
seed("A","SKU",0);
execute("DELETE FROM ll_selfop_sku_daily_sales WHERE business_date<DATE '"+END.minusDays(17)+"'");
// Unpublished/newer versions must not contribute to the selected report.
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));
for(int i=0;i<2;i++) {
var window=windows.get(i);
assertEquals("COMPLETE",window.dataStatus());assertEquals(window.days(),window.completeDayCount());
decimal("10",window.averageDailyOutboundQuantity());
}
var thirty=windows.get(2);
assertEquals(18,thirty.completeDayCount());assertEquals(12,thirty.unknownDayCount());assertNull(thirty.averageDailyOutboundQuantity());
assertEquals("PARTIAL",thirty.dataStatus());
}
@Test void warehouseChartUsesSalesUnitsAndCoverageIndependentlyOfStockProfiles() throws Exception {
seed("A","SKU",30);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN'");
@@ -424,6 +441,48 @@ class InventoryTurnoverSqlTest {
var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);Object raw=row.get("payload");
return raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"PHYSICAL","AVAILABLE","FROZEN","DEFECTIVE","AVERAGE_PHYSICAL","TURNOVER","COVERAGE","OUTBOUND_7","OUTBOUND_15","OUTBOUND_30","AVERAGE_DAILY"})
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END");
for(String group:List.of("SKU","OWNER")) {
if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue;
for(String direction:List.of("ASC","DESC")) {
var q=request(group,"ALL",metric+"_"+direction,null,null,1,20);
assertDoesNotThrow(()->q.validate(END.plusDays(1)));
var p=input(q,30,30,true);
// Turnover divides sales by average stock, so its ordering reverses here.
boolean lowFirst=direction.equals("ASC")!=metric.equals("TURNOVER");
var expected=lowFirst?List.of("L","H","U"):List.of("H","L","U");
assertEquals(expected,reportRows(p).stream().map(r->r.get("customer_code")).toList(),group+" "+q.sort());
assertEquals(expected,query("page",p).stream().map(r->r.get("customer_code")).toList());
}
}
}
@Test void averageDailySortUsesSelectedWindowBeforePaginationAndDoesNotSortObservedSubtotals() throws Exception {
seed("A","RECENT",0);seed("B","OLDER",0);seed("C","UNKNOWN",0);
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN sku_code='RECENT' THEN CASE WHEN business_date>=DATE '"+END.minusDays(6)+"' THEN 10 ELSE 0 END ELSE CASE WHEN business_date<DATE '"+END.minusDays(6)+"' THEN 20 ELSE 0 END END");
execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'");
for(int days:List.of(7,15,30)) {
for(int page=1;page<=3;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context");
var expected=days==7?List.of("RECENT","OLDER","UNKNOWN"):List.of("OLDER","RECENT","UNKNOWN");
assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code"));
}
}
var q=request("SKU","ALL","OUTBOUND_30_ASC",null,null,1,20);
var rows=reportRows(input(q,15,0,true));
assertTrue(rows.stream().allMatch(r->r.get("w30_outbound")==null));
assertEquals(List.of("OLDER","RECENT","UNKNOWN"),rows.stream().map(r->r.get("sku_code")).toList());
}
@Test void rejectsUnsupportedSortNamesAndGroupingInsteadOfInterpolatingSql() {
for(String sort:List.of("physical ASC","PHYSICAL_ASC;DROP TABLE x","AVERAGE_DAILY","UNKNOWN_DESC"))
assertThrows(RuntimeException.class,()->request("SKU","ALL",sort,null,null,1,20).validate(END.plusDays(1)));
assertThrows(RuntimeException.class,()->request("OWNER","ALL","COVERAGE_ASC",null,null,1,20).validate(END.plusDays(1)));
assertThrows(RuntimeException.class,()->request("SKU","ALL","NO_OUTBOUND_PHYSICAL_DESC",null,null,1,20).validate(END.plusDays(1)));
}
private List<Map<String,Object>> reportRows(InventoryTurnoverSql.Input p) throws Exception {
var result=new ArrayList<Map<String,Object>>();var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
for(var row:query("report",p)) if("ROW".equals(row.get("section")))
@@ -23,14 +23,13 @@ class InventoryWindowPolicyTest {
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY.plusDays(1)),DAY));
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(7,null),DAY));
}
@Test void tenDayAndHistoricalWindowsCannotMasqueradeAsThirtyDayDrilldowns() {
@Test void selectedWindowPredicatesWorkForTenDayAndHistoricalWindows() {
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,"NO_OUTBOUND_IN_WINDOW",null,null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,null,"OUTBOUND_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));
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null)})
assertEquals(10,InventoryWindowPolicy.range(o,DAY).dayCount());
assertThrows(BizException.class,()->options(10,null).validate("OVERVIEW",null));
}
@Test void tenDayFormulaKeepsMissingAndZeroSalesDistinctAndAvoidsIntermediateRounding() {
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** Executes the production scheduling predicates offline; only date arithmetic syntax is adapted to H2. */
class SelfopContinuousDueSqlTest {
private Connection db;
private MappedStatement statement;
private static final LocalDateTime MIDNIGHT = LocalDateTime.of(2026,10,14,16,0);
@BeforeEach void setup() throws Exception {
db=DriverManager.getConnection("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE","sa","");
execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,business_root_task_id BIGINT,source_account VARCHAR,execution_purpose VARCHAR,validation_root_task_id BIGINT,status VARCHAR,request_key VARCHAR,requested_by VARCHAR,task_role VARCHAR,task_type VARCHAR,next_retry_at_utc TIMESTAMP,planned_at_utc TIMESTAMP,query_until_utc TIMESTAMP,attempt_count INT)");
var config=new Configuration();config.addMapper(SelfopCoordinationMapper.class);
statement=config.getMappedStatement(SelfopCoordinationMapper.class.getName()+".dueContinuous");
}
@AfterEach void close() throws Exception {db.close();}
private void execute(String sql,Object...args) throws Exception {
try(var p=db.prepareStatement(sql)){for(int n=0;n<args.length;n++)p.setObject(n+1,args[n]);p.executeUpdate();}
}
private void inventory(long id,String status,int attempts,LocalDateTime planned,LocalDateTime retry) throws Exception {
execute("INSERT INTO ll_selfop_sync_task VALUES (?,?,'SYNTH-ACCOUNT','BUSINESS',NULL,?,?,'SELFOP_CONTINUOUS_PLANNER','WAREHOUSE_SYNC','SCHEDULED',?,?,NULL,?)",
id,id,status,"continuous:inventory:SYNTH-WH:"+planned,retry,planned,attempts);
}
private List<Long> due(LocalDateTime now) throws Exception {
var params=Map.of("now",now,"limit",20,"account","SYNTH-ACCOUNT");
var bound=statement.getBoundSql(params);
String sql=bound.getSql()
.replace("DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR)","DATEADD(HOUR,8,r.planned_at_utc)")
.replace("DATE_ADD(?,INTERVAL 8 HOUR)","DATEADD(HOUR,8,CAST(? AS TIMESTAMP))")
.replace("DATE_SUB(DATE(DATEADD(HOUR,8,CAST(? AS TIMESTAMP))),INTERVAL 8 HOUR)","DATEADD(HOUR,-8,DATE(DATEADD(HOUR,8,CAST(? AS TIMESTAMP))))");
try(var p=db.prepareStatement(sql)){
new DefaultParameterHandler(statement,params,bound).setParameters(p);
try(var rows=p.executeQuery()){var result=new ArrayList<Long>();while(rows.next())result.add(rows.getLong("id"));return result;}
}
}
@Test void retryAfterBeijingMidnightWaitsUntilBackoffThenRemainsDispatchable() throws Exception {
// Failure at 23:59:55 + the first 10-second backoff lands on the next Beijing day.
inventory(1,"RETRY_WAIT",1,MIDNIGHT.minusHours(16),MIDNIGHT.plusSeconds(5));
assertEquals(List.of(),due(MIDNIGHT.minusSeconds(5)));
assertEquals(List.of(),due(MIDNIGHT));
assertEquals(List.of(1L),due(MIDNIGHT.plusSeconds(5)));
assertEquals(List.of(1L),due(MIDNIGHT.plusDays(1)));
}
@ParameterizedTest @ValueSource(strings={"READY","RETRY_WAIT","RUNNING"})
void startedInventoryCanResumeAfterRestartAcrossMidnight(String status) throws Exception {
inventory(1,status,1,MIDNIGHT.minusHours(16),MIDNIGHT.minusSeconds(1));
assertEquals(List.of(1L),due(MIDNIGHT));
}
@Test void oldUnstartedSlotsAndTerminalOrUnauthorizedWorkRemainExcluded() throws Exception {
var yesterday=MIDNIGHT.minusHours(16);var today=MIDNIGHT.plusHours(8);
inventory(1,"READY",0,yesterday,null);
inventory(2,"DEAD",3,yesterday,null);
inventory(3,"SUCCEEDED",1,yesterday,null);
inventory(4,"READY",0,today,today);
inventory(5,"RETRY_WAIT",1,yesterday,null);
execute("UPDATE ll_selfop_sync_task SET source_account='OTHER-ACCOUNT' WHERE id=5");
inventory(6,"RETRY_WAIT",1,yesterday,null);
execute("UPDATE ll_selfop_sync_task SET execution_purpose='VALIDATION',validation_root_task_id=id WHERE id=6");
inventory(7,"RETRY_WAIT",1,yesterday,null);
execute("UPDATE ll_selfop_sync_task SET requested_by='untrusted' WHERE id=7");
inventory(8,"RETRY_WAIT",1,today.plusDays(1),null);
assertEquals(List.of(),due(MIDNIGHT));
assertEquals(List.of(4L),due(today));
}
}