From 496fc11b0af1213bd6c52ac97418ccec6195377d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 12:35:39 +0800 Subject: [PATCH] feat(selfop): support complete inventory sales windows --- .../PlatformAnalyticsController.java | 4 +- .../query/AnalyticsInventoryOptions.java | 6 +- .../query/AnalyticsInventorySql.java | 8 ++- .../query/AnalyticsQueryService.java | 34 +++++------ .../analytics/query/AnalyticsResponses.java | 8 +++ .../query/DashboardQueryService.java | 4 +- .../query/InventoryWindowPolicy.java | 43 +++++++++++++ .../openapi/self-operated-analytics-v1.yaml | 49 ++++++++++++++- .../PlatformAnalyticsControllerTest.java | 4 +- .../query/AnalyticsQueryServiceTest.java | 30 +++++++--- .../query/AnalyticsReadMapperTest.java | 10 ++-- .../query/AnalyticsSqlSemanticTest.java | 60 ++++++++++++++----- .../analytics/query/DashboardPolicyTest.java | 14 ++--- .../query/DashboardQueryServiceTest.java | 6 +- .../query/DashboardSqlSemanticTest.java | 6 +- .../query/InventoryWindowPolicyTest.java | 55 +++++++++++++++++ 16 files changed, 269 insertions(+), 72 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java index bc10ee39f..61ea808c1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java @@ -52,8 +52,8 @@ public class PlatformAnalyticsController { requirePlatform(user);return ApiResponse.success(service.slowSkus(query)); } @GetMapping("/inventory-coverage") - @Operation(summary="正品库存、固定30天日均和可售天数") - public ApiResponse> inventoryCoverage( + @Operation(summary="正品库存、10或30完整日日均和预计可售天数") + public ApiResponse> inventoryCoverage( @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java index 7f1145fe3..ba76bb012 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java @@ -5,12 +5,16 @@ import java.util.Set; /** Endpoint-specific inventory presentation, separate from immutable version/date identity. */ public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold, - String coverageBucket,String bucketRuleVersion,String coverageSelection,String drilldownFrom) { + String coverageBucket,String bucketRuleVersion,String coverageSelection,String drilldownFrom, + Integer salesWindowDays,@org.springframework.format.annotation.DateTimeFormat(iso=org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate salesWindowEndDate) { + public int salesDays(){return salesWindowDays==null?30:salesWindowDays;} public String attentionValue(){return attention==null?"ALL":attention;} public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;} public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} public void validate(String endpoint,AnalyticsQuery q){ CoverageDistributionPolicy.validate(this); + if(salesDays()!=10&&salesDays()!=30)throw new BizException("库存销量窗口仅支持10或30天"); + if(!"INVENTORY".equals(endpoint)&&(salesWindowDays!=null||salesWindowEndDate!=null))throw new BizException("销量窗口仅用于库存明细"); if(!"INVENTORY".equals(endpoint)&&(coverageBucket!=null||coverageSelection!=null||drilldownFrom!=null))throw new BizException("分布下钻条件仅用于库存明细"); if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数"); if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java index 7fddf053a..ad8ade2ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -20,6 +20,8 @@ public final class AnalyticsInventorySql { static String dashboardCte(AnalyticsSqlRequest p){return cte(p,false,true);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst){return cte(p,stockFirst,false);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst,boolean dashboard){ + String days="INVENTORY".equals(p.kind())?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; + String complete="INVENTORY".equals(p.kind())?"#{p.completeWindow}":"#{p.complete30}"; return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," @@ -38,16 +40,16 @@ public final class AnalyticsInventorySql { +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," - +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," + +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND "+complete+"=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count " +"FROM profiles c "+(stockFirst?"JOIN stock_paged pk ON pk.customer_code=c.customer_code AND pk.sku_code=c.sku_code ":"") +"LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code)," +" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible," +"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v)," - +" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days," + +" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*"+days+"/outbound_quantity30d END coverage_days," +"CASE WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d=0 THEN 1 ELSE 0 END END no_outbound," +"CASE WHEN #{o.coverageDaysThreshold} IS NULL THEN NULL WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND available_quantity=0 THEN 0 " - +"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold " + +"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*"+days+">outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold " +"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) "; } private static String salesCte(AnalyticsSqlRequest p,boolean stockFirst,boolean unused){String d=stockFirst?"d.":""; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java index 603fa2a2d..04ecd05e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -29,13 +29,13 @@ public class AnalyticsQueryService { LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){} public AnalyticsResponses.Page salesSummary(AnalyticsQuery q){ - Snapshot s=context(q,true,false,false); AnalyticsSqlRequest p=request(s,"SUMMARY"); + Snapshot s=context(q,true,false,false,null); AnalyticsSqlRequest p=request(s,"SUMMARY"); long count=mapper.count(p); var records=mapper.page(p).stream().map(r->summary(s,r)).toList(); return page(s,records,count,Map.of()); } public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){ - Snapshot s=context(q,true,true,false); + Snapshot s=context(q,true,true,false,null); Profile sp=profile(s,s.sales),ip=profile(s,s.inventory); if(sp==null&&ip==null)throw new BizException("所选固定版本中无该货主和SKU记录"); String unit=sp!=null?sp.quantityUnit():ip==null?null:ip.quantityUnit(); @@ -66,18 +66,18 @@ public class AnalyticsQueryService { } public AnalyticsResponses.Page hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);} public AnalyticsResponses.Page slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);} - public AnalyticsResponses.Page inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){ - o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY"); + public AnalyticsResponses.Page inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false,o);var p=request(s,"INVENTORY"); CoverageDistributionPolicy.requireDrilldown(q,o,s.context(),inventoryFreshUntil(s),clock.instant()); long total=mapper.inventoryCount(p,o); return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of()); } public AnalyticsResponses.Page owners(AnalyticsQuery q,AnalyticsInventoryOptions o){ - o.validate("OWNERS",q);Snapshot s=context(q,false,false,false);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o); + o.validate("OWNERS",q);Snapshot s=context(q,false,false,false,null);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o); return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of()); } public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){ - o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o); + o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false,null);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o); Map totals=new HashMap<>();if(raw!=null)raw.forEach((key,value)->totals.put(key.toLowerCase(Locale.ROOT),value)); long count=number(totals,"sku_count");if(count==0)return null; var physical=quantity(totals,"physical_quantity");var outbound=quantity(totals,"owner_outbound"); @@ -90,12 +90,12 @@ public class AnalyticsQueryService { quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average, risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null)); } - private AnalyticsResponses.Metric inventoryMetric(Snapshot s,Inventory r){ + private AnalyticsResponses.InventoryMetric inventoryMetric(Snapshot s,Inventory r){ boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID"; String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT"; - var calculated=InventoryCoveragePolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted, - complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid())); - List attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_30D"); + var calculated=InventoryWindowPolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted, + complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid()),s.range.dayCount()); + List attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_IN_WINDOW"); if(Integer.valueOf(1).equals(r.aboveThreshold()))attention.add("COVERAGE_GT_THRESHOLD"); if(positive(r.frozenQuantity()))attention.add("FROZEN_STOCK");if(positive(r.defectivePhysicalQuantity()))attention.add("DEFECTIVE_STOCK"); if(Boolean.TRUE.equals(r.unassessable()))attention.add("UNASSESSABLE"); @@ -106,10 +106,10 @@ public class AnalyticsQueryService { Map statuses=new LinkedHashMap<>();String unknown="STALE".equals(s.context.inventoryStatus())?"STALE":"UNKNOWN"; statuses.put("physicalQuantity",r.physicalQuantity()==null?unknown:"VALID");statuses.put("availableQuantity",r.availableQuantity()==null?unknown:"VALID"); statuses.put("frozenQuantity",r.frozenQuantity()==null?unknown:"VALID");statuses.put("transitQuantity",r.transitQuantity()==null?unknown:"VALID"); - statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("outboundQuantity30d",r.outboundQuantity30d()==null?"UNKNOWN":"VALID"); - return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null, - r.outboundQuantity30d(),r.orderCount(),null,r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()), - divide30(r.outboundQuantity30d()),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)), + statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("windowOutboundQuantity",r.outboundQuantity30d()==null?"UNKNOWN":"VALID"); + return new AnalyticsResponses.InventoryMetric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null, + r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()), + r.outboundQuantity30d()==null?null:r.outboundQuantity30d().divide(BigDecimal.valueOf(s.range.dayCount()),12,java.math.RoundingMode.HALF_UP),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)), null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses)); } private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;} @@ -132,7 +132,7 @@ public class AnalyticsQueryService { unknown,decimal(row,field+"_unknown_physical"),reasons); } private AnalyticsResponses.Page metrics(AnalyticsQuery q,String kind,boolean hot){ - Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind); + Snapshot s=context(q,false,false,hot,null); AnalyticsSqlRequest p=request(s,kind); List statistics=mapper.rankingStats(p); long total=statistics.isEmpty()?0:statistics.get(0).total(); List rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind)).toList(); @@ -173,7 +173,7 @@ public class AnalyticsQueryService { stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(), r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of()); } - Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){ + Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot,AnalyticsInventoryOptions inventoryOptions){ q.validate(chart,exact,hot); Instant now=clock.instant(); List scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode()); Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY"); @@ -199,7 +199,7 @@ public class AnalyticsQueryService { LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); SalesRangePolicy.Range range; try{ - range=chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays): new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30, anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java index 2b6c872d8..4a57b2942 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java @@ -29,6 +29,14 @@ public final class AnalyticsResponses { String status,List reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity, BigDecimal outboundQuantity30d,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate, List attentionReasons,Map quantityStatuses){} + public record InventoryMetric(String sourceAccount,String customerCode,String warehouseCode,String sku,String productName, + String quantityUnit,Long skuOutboundOrderCount, + BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,BigDecimal transitQuantity, + Instant inventoryObservedAt,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays, + String inventoryQuality,String salesCoverage,boolean observationEligible,Instant earliestReliableEvidenceAt, + String status,List reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity, + BigDecimal windowOutboundQuantity,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate, + List attentionReasons,Map quantityStatuses){} public record OwnerOption(String customerCode,String customerName){} public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List reasonCodes){} public record RiskShare(Long matchedSkuCount,BigDecimal matchedPhysicalQuantity,BigDecimal physicalQuantityRatio, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java index f20eb96ab..14837712a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java @@ -17,13 +17,13 @@ public class DashboardQueryService { public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper){this.contextService=contextService;this.mapper=mapper;} public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){ AnalyticsQuery q=DashboardRequest.normalize(input); - var snapshot=contextService.context(q,true,false,false);var c=snapshot.context(); + var snapshot=contextService.context(q,true,false,false,null);var c=snapshot.context(); if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady())) throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询"); var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom, base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD"); - var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null); + var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null); var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive()); var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList(); Map stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java new file mode 100644 index 000000000..376e45092 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesRangePolicy; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; + +/** Inventory-only complete natural-day window; dashboard and ranking policies remain fixed. */ +final class InventoryWindowPolicy { + private InventoryWindowPolicy() {} + static SalesRangePolicy.Range range(AnalyticsInventoryOptions o,LocalDate anchor) { + int days=o.salesDays(); + if(days!=10&&days!=30)throw new BizException("库存销量窗口仅支持10或30天"); + LocalDate end=o.salesWindowEndDate()==null?anchor.minusDays(1):o.salesWindowEndDate(); + if(!end.isBefore(anchor))throw new BizException("库存销量截止日必须早于查询当日"); + boolean defaultThirty=days==30&&end.equals(anchor.minusDays(1)); + if(!defaultThirty&&(o.coverageBucket()!=null||o.coverageSelection()!=null||o.drilldownFrom()!=null + ||"NO_OUTBOUND_30D".equals(o.attentionValue())||"OUTBOUND_30D_ASC".equals(o.sortValue()))) + throw new BizException("30日关注、排序及总览下钻仅适用于截至昨日的30日窗口"); + LocalDate until=end.plusDays(1),from=until.minusDays(days);var zone=ZoneId.of("Asia/Shanghai"); + return new SalesRangePolicy.Range(anchor,from,until,days,from.atStartOfDay(zone).toInstant(),until.atStartOfDay(zone).toInstant()); + } + record Result(BigDecimal average,BigDecimal days,String status) {} + static Result calculate(String quality,BigDecimal available,BigDecimal sales,boolean trusted, + boolean complete,boolean observed,boolean unit,int days) { + if(days!=10&&days!=30)throw new IllegalArgumentException("Inventory window must be 10 or 30 days"); + if(!"VALID".equals(quality)||available==null)return new Result(null,null,"INVENTORY_"+quality); + if(available.signum()<0)return new Result(null,null,"INVENTORY_INVALID"); + BigDecimal divisor=BigDecimal.valueOf(days); + BigDecimal average=unit&&trusted&&complete&&observed&&sales!=null&&sales.signum()>=0 + ?sales.divide(divisor,12,RoundingMode.HALF_UP):null; + if(available.signum()==0)return new Result(average,BigDecimal.ZERO.setScale(1),"NO_AVAILABLE_INVENTORY"); + if(!unit)return new Result(null,null,"UNIT_UNKNOWN_OR_CONFLICT"); + if(!trusted)return new Result(null,null,"SALES_UNTRUSTED"); + if(!complete)return new Result(null,null,"COVERAGE_INSUFFICIENT"); + if(!observed)return new Result(null,null,"OBSERVATION_INSUFFICIENT"); + if(sales==null||sales.signum()<0)return new Result(null,null,"SALES_UNKNOWN"); + if(sales.signum()==0)return new Result(average,null,"NO_OUTBOUND_IN_WINDOW"); + return new Result(average,available.multiply(divisor).divide(sales,1,RoundingMode.HALF_UP),"VALID"); + } +} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index bbd170bb3..33dd624b9 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -179,8 +179,8 @@ paths: '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/inventory-coverage: get: - summary: 正品库存与固定30日可售天数 - description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' + summary: 正品库存与固定10或30日可售天数 + description: '固定10或30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' parameters: - {$ref: '#/components/parameters/coverageBucket'} - {$ref: '#/components/parameters/bucketRuleVersion'} @@ -189,6 +189,8 @@ paths: - {$ref: '#/components/parameters/attention'} - {$ref: '#/components/parameters/sortBy'} - {$ref: '#/components/parameters/coverageDaysThreshold'} + - {name: salesWindowDays, in: query, schema: {type: integer, enum: [10, 30], default: 30}, description: 仅库存明细使用的完整自然日窗口} + - {name: salesWindowEndDate, in: query, schema: {type: string, format: date}, description: 含当日的窗口截止日,默认固定查询基准的北京时间昨日;必须早于查询当日,分页须携带原窗口} - {$ref: '#/components/parameters/sourceAccount'} - {$ref: '#/components/parameters/warehouseCode'} @@ -211,7 +213,7 @@ paths: properties: code: {type: string} message: {type: string} - data: {$ref: '#/components/schemas/MetricPage'} + data: {$ref: '#/components/schemas/InventoryMetricPage'} '401': {description: 缺少可信认证身份} '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/sync-overview: @@ -652,6 +654,37 @@ components: earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true} status: {type: string, nullable: true} reasonCodes: {type: array, items: {type: string}} + InventoryMetric: + description: 库存独立窗口响应。windowOutboundQuantity为所选完整10或30日销量;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。30日专属筛选及总览下钻不能用于10日或历史截止窗口。 + type: object + properties: + customerName: {type: string, nullable: true} + defectivePhysicalQuantity: {type: number, nullable: true} + windowOutboundQuantity: {type: number, nullable: true} + lastObservedOutboundDate: {type: string, format: date, nullable: true} + observedHistoryStartDate: {type: string, format: date, nullable: true} + attentionReasons: {type: array, items: {type: string}} + quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE]}} + sourceAccount: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + quantityUnit: {type: string, nullable: true} + skuOutboundOrderCount: {type: string, nullable: true} + physicalQuantity: {type: number, nullable: true} + availableQuantity: {type: number, nullable: true} + frozenQuantity: {type: number, nullable: true} + transitQuantity: {type: number, nullable: true} + inventoryObservedAt: {type: string, format: date-time, nullable: true} + averageDailyOutboundQuantity: {type: number, nullable: true} + inventoryCoverageDays: {type: number, nullable: true} + inventoryQuality: {type: string, nullable: true} + salesCoverage: {type: string, nullable: true} + observationEligible: {type: boolean, nullable: true} + earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true} + status: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} Protection: type: object properties: @@ -816,6 +849,16 @@ components: total: {type: string, nullable: true} pages: {type: string, nullable: true} excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}} + InventoryMetricPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/InventoryMetric'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + pages: {type: string, nullable: true} + excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}} TaskPage: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java index 818bbe37b..972788348 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java @@ -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{ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java index 006a3f738..1740cac7a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java @@ -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, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java index 51150a96d..af3c646e7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java @@ -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 ?")0")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java index c30e8c0c7..4bdb3b348 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -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 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 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 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 inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ return (List)(List)literalQuery("inventoryPage",p,o); } @SuppressWarnings("unchecked") private List rankingStats(AnalyticsSqlRequest p){ - return (List)(List)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)); + return (List)(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 inventory(String attention,Integer threshold,String sort,long offset,int size){ - return (List)(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)(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 totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java index 04f2cf8aa..e19d6b5ba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java @@ -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()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java index f556cb7ef..2d21fc4fc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index bdf4eb095..d8ee3aad3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -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> report(){ return ((List>)(List)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{ Map normalized=new HashMap<>();row.forEach((key,value)->normalized.put(key.toLowerCase(Locale.ROOT),value));return normalized; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java new file mode 100644 index 000000000..a936e9016 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java @@ -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")); + } +}