From bc9c78122b0c015fc973a587fbe08011c5eedb75 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 12:44:15 +0800 Subject: [PATCH] feat(self-operated): support selected-day analytics and global quantity sorting --- .../PlatformAnalyticsController.java | 5 +- .../query/AnalyticsInventoryOptions.java | 6 +- .../query/AnalyticsInventorySql.java | 22 ++++--- .../analytics/query/AnalyticsQuery.java | 4 +- .../query/AnalyticsQueryService.java | 22 +++---- .../analytics/query/AnalyticsReadMapper.java | 2 + .../analytics/query/AnalyticsReadSql.java | 25 ++++---- .../analytics/query/AnalyticsResponses.java | 2 +- .../query/CoverageDistributionSql.java | 2 +- .../analytics/query/DashboardReadSql.java | 2 +- .../analytics/query/DashboardSalesPolicy.java | 7 ++- .../query/InventoryTurnoverContext.java | 18 ++++-- .../query/InventoryTurnoverPolicy.java | 8 +-- .../query/InventoryTurnoverQueryService.java | 2 +- .../query/InventoryTurnoverRequest.java | 5 +- .../query/InventoryTurnoverResponses.java | 2 +- .../analytics/query/InventoryTurnoverSql.java | 60 ++++++++++--------- .../query/InventoryWindowPolicy.java | 7 +-- .../analytics/query/SalesSort.java | 9 +++ .../openapi/self-operated-analytics-v1.yaml | 29 +++++---- .../self-operated-inventory-turnover-v1.yaml | 24 ++++---- .../PlatformAnalyticsControllerTest.java | 15 ++++- .../query/AnalyticsQueryServiceTest.java | 47 ++++++++++++--- .../query/AnalyticsReadMapperTest.java | 10 ++++ .../query/AnalyticsSqlSemanticTest.java | 43 +++++++++++-- .../analytics/query/DashboardPolicyTest.java | 20 ++++++- .../query/DashboardSqlSemanticTest.java | 16 +++++ .../query/InventoryTurnoverContextTest.java | 10 ++++ .../query/InventoryTurnoverRequestTest.java | 2 +- .../query/InventoryTurnoverSqlTest.java | 27 ++++++++- .../query/InventoryWindowPolicyTest.java | 20 +++---- 31 files changed, 329 insertions(+), 144 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesSort.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 d03f9f64a..fdcf2fdb3 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 @@ -30,8 +30,9 @@ public class PlatformAnalyticsController { @GetMapping("/sku-sales-summary") @Operation(summary="五周期或历史自定义范围SKU累计清单") public ApiResponse> salesSummary( - @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){ - requirePlatform(user);return ApiResponse.success(service.salesSummary(query)); + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query, + @RequestParam(defaultValue="QUANTITY_DESC") SalesSort salesSort){ + requirePlatform(user);return ApiResponse.success(service.salesSummary(query,salesSort)); } @GetMapping("/sku-daily-sales") @Operation(summary="精确货主与SKU的完整日趋势轴") 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 1d2f068d8..bf7955124 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 @@ -15,12 +15,14 @@ public record AnalyticsInventoryOptions(String keyword,String attention,String s CoverageDistributionPolicy.validate(this); if("DASHBOARD".equals(drilldownFrom)&&(salesWindowDays!=null||salesWindowEndDate!=null||!"CUSTOM".equals(q.rangePreset())||q.startDate()==null||q.endDate()==null)) throw new BizException("总览库存下钻须携带原图起止日期,不能混用库存窗口"); - if(salesDays()!=10&&salesDays()!=30)throw new BizException("库存销量窗口仅支持10或30天"); + if(salesDays()<1||salesDays()>366)throw new BizException("库存销量窗口须为1至366个完整自然日"); 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_IN_WINDOW","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); - if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_ASC").contains(sortValue()))throw new BizException("库存排序无效"); + if(!Set.of("AVAILABLE_ASC","AVAILABLE_DESC","COVERAGE_DAYS_ASC","COVERAGE_DAYS_DESC", + "PHYSICAL_ASC","PHYSICAL_DESC","FROZEN_ASC","FROZEN_DESC","DEFECTIVE_ASC","DEFECTIVE_DESC", + "TRANSIT_ASC","TRANSIT_DESC","OUTBOUND_ASC","OUTBOUND_DESC","AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC").contains(sortValue()))throw new BizException("库存排序无效"); if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值"); if("OWNERS".equals(endpoint)){ if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))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 a7877d253..9e95ca229 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 @@ -46,7 +46,8 @@ public final class AnalyticsInventorySql { +"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)," + // Complete source coverage proves actual window sales independently of SKU observation age. + +"CASE WHEN unit_valid=1 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*"+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 " @@ -77,9 +78,18 @@ public final class AnalyticsInventorySql { case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0"; case "UNASSESSABLE" -> " WHERE unassessable=1"; default -> "";};} - private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){ - case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_ASC" -> "outbound_quantity30d";default -> "available_quantity";}; - return field+" IS NULL,"+field+("OUTBOUND_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";} + private static String order(AnalyticsInventoryOptions o){return order(o,"");} + private static String order(AnalyticsInventoryOptions o,String prefix){String field=prefix+switch(o.sortValue()){ + case "AVAILABLE_ASC","AVAILABLE_DESC" -> "available_quantity"; + case "PHYSICAL_ASC","PHYSICAL_DESC" -> "physical_quantity"; + case "FROZEN_ASC","FROZEN_DESC" -> "frozen_quantity"; + case "DEFECTIVE_ASC","DEFECTIVE_DESC" -> "defective_physical_quantity"; + case "TRANSIT_ASC","TRANSIT_DESC" -> "transit_quantity"; + case "COVERAGE_DAYS_ASC","COVERAGE_DAYS_DESC" -> "coverage_days"; + // Every row has the same window denominator; sort the exact numerator. + case "OUTBOUND_ASC","OUTBOUND_DESC","AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC" -> "outbound_quantity30d"; + default -> throw new IllegalArgumentException("Unsupported inventory sort");}; + return field+" IS NULL,"+field+(o.sortValue().endsWith("_ASC")?" ASC":" DESC")+","+prefix+"sku_code ASC,"+prefix+"customer_code ASC";} public static String inventoryPage(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); // The full candidate set is filtered and sorted first. Historical dates enrich only // those immutable keys selected for this page and never participate in qualification. @@ -91,9 +101,7 @@ public final class AnalyticsInventorySql { +"JOIN paged k ON k.customer_code=d.customer_code AND k.sku_code=d.sku_code WHERE d.sales_run_id=#{p.salesRun} " +"AND d.source_account=#{p.account} AND d.warehouse_code=#{p.warehouse} AND d.business_date<=#{p.anchor} " +"AND d.outbound_quantity>0 AND d.quantity_unit='PCS' GROUP BY d.customer_code,d.sku_code) "); - String outerOrder=order(o).replace("physical_quantity","k.physical_quantity").replace("coverage_days","k.coverage_days") - .replace("outbound_quantity30d","k.outbound_quantity30d").replace("available_quantity","k.available_quantity") - .replace("sku_code","k.sku_code").replace("customer_code","k.customer_code"); + String outerOrder=order(o,"k."); return typed(paged+"SELECT k.customer_code,k.sku_code,k.product_name,k.unit_valid,k.evidence_at_utc,k.physical_quantity,k.available_quantity,k.frozen_quantity,k.transit_quantity,k.defective_physical_quantity,k.observed_at_utc,k.owner_outbound,k.observed_outbound,k.order_count,"+(stockFirst?lastOutboundLookup():"l.last_outbound")+" last_outbound,k.observation_eligible,k.outbound_quantity30d,k.coverage_days,k.no_outbound,k.above_threshold,k.unassessable FROM paged k "+(stockFirst?"":"LEFT JOIN last_sales l ON l.customer_code=k.customer_code AND l.sku_code=k.sku_code ")+"ORDER BY "+outerOrder);} private static String lastOutboundLookup(){ // Repeat the correlated equality keys in ORDER BY so MySQL can traverse the covering index in reverse. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java index 361e376ba..41c8b84cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java @@ -30,8 +30,8 @@ public record AnalyticsQuery(String sourceAccount, String warehouseCode, String if(chart){ if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays"); if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度"); - }else if(hot&&"CUSTOM".equals(rangePreset)){ - if(startDate==null||endDate==null||windowDays!=null||bucket!=null)throw new BizException("自定义热销窗口须提供起止日期,不接受windowDays或bucket"); + }else if("CUSTOM".equals(rangePreset)){ + if(startDate==null||endDate==null||windowDays!=null||bucket!=null)throw new BizException("自定义榜单窗口须提供起止日期,不接受windowDays或bucket"); }else{ if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数"); if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天"); 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 306a38d7b..c32906d0d 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 @@ -28,10 +28,10 @@ public class AnalyticsQueryService { record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted, LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){} - public AnalyticsResponses.Page salesSummary(AnalyticsQuery q){ + public AnalyticsResponses.Page salesSummary(AnalyticsQuery q,SalesSort salesSort){ 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(); + var records=mapper.salesPage(p,salesSort).stream().map(r->summary(s,r)).toList(); return page(s,records,count,Map.of()); } public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){ @@ -97,7 +97,7 @@ public class AnalyticsQueryService { 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=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()); + complete(s,s.range.startDate(),s.range.endDateExclusive()),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"); @@ -109,11 +109,13 @@ 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("windowOutboundQuantity",r.outboundQuantity30d()==null?"UNKNOWN":"VALID"); + statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID"); + statuses.put("windowOutboundQuantity",r.outboundQuantity30d()!=null?"VALID":!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT": + !s.trusted?"SALES_UNTRUSTED":!"COMPLETE".equals(coverage)?"COVERAGE_INSUFFICIENT":"UNKNOWN"); 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)); + null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.observedOutbound(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses)); } private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;} private static LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream() @@ -155,12 +157,12 @@ public class AnalyticsQueryService { } private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r,String kind){ Instant evidence=utc(r.evidenceAtUtc()); - boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant()); + boolean observed=evidence!=null&&!evidence.isAfter(s.range.startDate().atStartOfDay(BUSINESS_ZONE).toInstant()); boolean stockValid="VALID".equals(r.inventoryQuality()); String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":!s.trusted?"SALES_UNTRUSTED": complete(s,s.range.startDate(),s.range.endDateExclusive())?"COMPLETE":"COVERAGE_INSUFFICIENT"; - var result=InventoryCoveragePolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.sales30Quantity(), - s.trusted,complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),observed,Boolean.TRUE.equals(r.unitValid())); + var result=InventoryWindowPolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.windowQuantity(), + s.trusted,complete(s,s.range.startDate(),s.range.endDateExclusive())&&observed,Boolean.TRUE.equals(r.unitValid()),s.range.dayCount()); List reasons=new ArrayList<>(s.context.reasonCodes()); if(!"COMPLETE".equals(coverage))reasons.add(coverage); if(!"VALID".equals(result.status()))reasons.add(result.status()); @@ -205,7 +207,7 @@ public class AnalyticsQueryService { LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); SalesRangePolicy.Range range; try{ - range=inventoryOptions!=null&&!dashboardWindow?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||dashboardWindow||(hot&&"CUSTOM".equals(q.rangePreset())))?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + range=inventoryOptions!=null&&!dashboardWindow?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||dashboardWindow||("CUSTOM".equals(q.rangePreset())))?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()); @@ -244,7 +246,7 @@ public class AnalyticsQueryService { AnalyticsSqlRequest request(Snapshot s,String kind){ int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset())); return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(), - s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()), + s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.startDate(), s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown, s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()), s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind,SalesQueryCoverage.intervals(s.sales)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java index 43ec5edb4..1c5cfb771 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java @@ -18,6 +18,8 @@ public interface AnalyticsReadMapper { AnalyticsReadRows.Run publishedRun(@Param("id") long id); @SelectProvider(type=AnalyticsReadSql.class,method="page") List page(@Param("p") AnalyticsSqlRequest request); + @SelectProvider(type=AnalyticsReadSql.class,method="page") + List salesPage(@Param("p") AnalyticsSqlRequest request,@Param("salesSort") SalesSort salesSort); @SelectProvider(type=AnalyticsReadSql.class,method="count") long count(@Param("p") AnalyticsSqlRequest request); record RankingStat(Long total,String reason,Long excludedCount){} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java index 221c70bf1..453b2340c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -28,9 +28,10 @@ public final class AnalyticsReadSql { } return sql; } - private static String cte(AnalyticsSqlRequest request){ + private static String cte(AnalyticsSqlRequest request){return cte(request,SalesSort.QUANTITY_DESC);} + private static String cte(AnalyticsSqlRequest request,SalesSort salesSort){ String kind=request.kind(); - if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request); + if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request,salesSort); if("HOT".equals(kind))return hotSalesCte(); if("SLOW".equals(kind))return rankingCte(); String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; @@ -113,8 +114,7 @@ public final class AnalyticsReadSql { +"AND #{p.salesKnown}=1 AND (COALESCE(s.actual_days,0)>0 OR ("+observedIntersection+")) " +"THEN COALESCE(s.observed_sum,0) END sort_observed_quantity " +"FROM evidence e LEFT JOIN sort_sales s ON s.customer_code=e.customer_code AND s.sku_code=e.sku_code)," - +" paged_keys AS (SELECT * FROM rankable ORDER BY sort_observed_quantity IS NULL,sort_observed_quantity DESC," - +"sku_code ASC,customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}),"; + +" paged_keys AS (SELECT * FROM rankable ORDER BY "+salesSort.sql("sort_observed_quantity")+" LIMIT #{p.size} OFFSET #{p.offset}),"; sql=sql.substring(0,rangeStart)+" ranged AS (SELECT d.customer_code,d.sku_code,SUM(d.outbound_quantity) observed_sum," +"COUNT(*) actual_days,SUM(d.outbound_order_count) order_sum,MIN(d.quantity_unit) min_unit,MAX(d.quantity_unit) max_unit," +"SUM(CASE WHEN d.business_date>=#{p.completeFrom} AND d.business_date<#{p.completeUntil} AND d.business_date<#{p.anchor} THEN 1 ELSE 0 END) actual_complete " @@ -153,7 +153,7 @@ public final class AnalyticsReadSql { } return sql; } - private static String coveredSummary(AnalyticsSqlRequest p){ + private static String coveredSummary(AnalyticsSqlRequest p,SalesSort salesSort){ var intervals=new java.util.ArrayList(); for(int i=0;i(); @@ -169,7 +169,7 @@ public final class AnalyticsReadSql { +"CASE WHEN #{p.salesKnown}=1 AND e.profile_unit_valid=1 AND (s.min_unit IS NULL OR (s.min_unit=e.quantity_unit AND s.max_unit=e.quantity_unit AND s.min_qty>=0))" +" AND (s.observed_sum IS NOT NULL OR EXISTS (SELECT 1 FROM coverage v WHERE GREATEST(v.lo,e.evidence_at_utc,TIMESTAMPADD(HOUR,-8,CAST(#{p.from} AS DATETIME)))=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity," - +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders," + +"SUM(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN outbound_quantity ELSE 0 END) window_quantity," + +"SUM(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN outbound_order_count ELSE 0 END) window_orders," +"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," +"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity ELSE NULL END) today_quantity,MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit " +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER - +" AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date=LEAST(#{p.from},TIMESTAMPADD(DAY,-30,#{p.anchor})) AND business_date0 "; if("SLOW".equals(kind))return " WHERE window_quantity IS NOT NULL AND inventory_quality='VALID' AND available_quantity>0 " - +"AND evidence_at_utc<=CONVERT_TZ(CAST(TIMESTAMPADD(DAY,-30,#{p.anchor}) AS DATETIME),'+08:00','+00:00') "; + +"AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') "; return ""; } private static String order(String kind){ @@ -223,10 +223,11 @@ public final class AnalyticsReadSql { public static String page(java.util.Map parameters){ var p=(AnalyticsSqlRequest)parameters.get("p"); if("HOT".equals(p.kind()))return typed(hotPage(),p); - return typed(cte(p)+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," + SalesSort salesSort=(SalesSort)parameters.getOrDefault("salesSort",SalesSort.QUANTITY_DESC); + return typed(cte(p,salesSort)+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," +"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity," +"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason " - +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind()) + +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+("SUMMARY".equals(p.kind())?salesSort.sql("observed_quantity"):order(p.kind())) +("SUMMARY".equals(p.kind())?"":" LIMIT #{p.size} OFFSET #{p.offset}"),p); } /** Rank on sales alone. Inventory enrichment and its quality checks touch only the 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 4a57b2942..acada1b11 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 @@ -35,7 +35,7 @@ public final class AnalyticsResponses { 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, + BigDecimal windowOutboundQuantity,BigDecimal observedWindowOutboundQuantity,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){} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java index 19c04df8e..22adcd643 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java @@ -13,7 +13,7 @@ public final class CoverageDistributionSql { +"WHEN available_quantity*"+days+"<=outbound_quantity30d*90 THEN 'DAYS_60_90' ELSE 'DAYS_GT_90' END";} public static String unknownReason(){return "CASE WHEN physical_quantity IS NULL THEN 'PHYSICAL_INVENTORY_UNKNOWN' WHEN available_quantity IS NULL THEN 'AVAILABLE_INVENTORY_UNKNOWN' " +"WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN owner_outbound IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' " - +"WHEN observation_eligible=0 THEN 'OBSERVATION_INSUFFICIENT' ELSE 'METRIC_DEPENDENCY_UNKNOWN' END";} + +"ELSE 'METRIC_DEPENDENCY_UNKNOWN' END";} public static String filter(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ if(o.coverageBucket()!=null)return "#{p.inventoryRun}>0 AND "+candidate()+" AND ("+bucket(p)+")=#{o.coverageBucket}"; if(o.coverageSelection()!=null)return finitePositive(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java index 980e406d6..716d22a07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java @@ -34,7 +34,7 @@ public final class DashboardReadSql { .append(",SUM(CASE WHEN bucket_code='").append(code).append("' THEN physical_quantity END) physical_").append(alias) .append(",COUNT(CASE WHEN bucket_code='").append(code).append("' AND physical_quantity IS NULL THEN 1 END) unknown_").append(alias); } - for(String reason:List.of("PHYSICAL_INVENTORY_UNKNOWN","AVAILABLE_INVENTORY_UNKNOWN","UNIT_UNKNOWN_OR_CONFLICT","SALES_WINDOW_UNAVAILABLE","OBSERVATION_INSUFFICIENT","METRIC_DEPENDENCY_UNKNOWN")) + for(String reason:List.of("PHYSICAL_INVENTORY_UNKNOWN","AVAILABLE_INVENTORY_UNKNOWN","UNIT_UNKNOWN_OR_CONFLICT","SALES_WINDOW_UNAVAILABLE","METRIC_DEPENDENCY_UNKNOWN")) sql.append(",COUNT(CASE WHEN bucket_code='UNKNOWN' AND unknown_reason='").append(reason).append("' THEN 1 END) reason_").append(reason.toLowerCase(java.util.Locale.ROOT)); sql.append(" FROM classified)") .append(", hot_rank AS (SELECT * FROM dashboard_result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java index 69a62064e..14d8c2330 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java @@ -21,14 +21,15 @@ public final class DashboardSalesPolicy { Instant upper=interval==null?null:(interval.until().toInstant(ZoneOffset.UTC).isBefore(end)?interval.until().toInstant(ZoneOffset.UTC):end); boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper); boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0; - // Global recency describes how far collection has progressed, not whether an - // already published historical day has complete coverage. Keep STALE on context. - boolean trusted=("VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus()))&&!c.rebuildRequired(); + // Recency and pending rebuild describe the live scope, not the frozen version's + // historical coverage. Inventory assessment retains its separate rebuild guard. + boolean trusted="VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus())||"REBUILD_REQUIRED".equals(c.salesStatus()); boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&SalesQueryCoverage.complete(intervals,day,day.plusDays(1)); BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null; if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO; String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List reasons=new ArrayList<>(); if(!sourceKnown)reasons.add("SALES_NOT_COVERED");if(!trusted)reasons.add("SALES_"+c.salesStatus()); + if(c.rebuildRequired()||"REBUILD_REQUIRED".equals(c.salesStatus()))reasons.add("SALES_REBUILD_REQUIRED"); if(invalid)reasons.add("SALES_QUANTITY_OR_UNIT_INVALID");if(!complete)reasons.add(day.equals(c.anchorDate())?"TODAY_INCOMPLETE":"SALES_DAY_INCOMPLETE"); if(known==null)reasons.add("SALES_NOT_COVERED"); result.add(new SalesBucketPolicy.Point(day,complete?known:null,known,null,status,List.copyOf(new LinkedHashSet<>(reasons)),intersects?lower:null,intersects?upper:null)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java index a82f60708..5f3abca3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java @@ -26,7 +26,7 @@ public class InventoryTurnoverContext { } public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf, Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild, - List samples,LocalDate accumulationStartedOn) {} + String samplesHash,LocalDate accumulationStartedOn) {} record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {} Frozen resolve(InventoryTurnoverRequest q) { Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate()); @@ -38,22 +38,22 @@ public class InventoryTurnoverContext { ||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh(); var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode()); var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf); - var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(29),end,"DAY",null,1,20, + var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20, sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null); String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); var mapping=new TreeMap(); - for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(29),end.plusDays(1))) { - if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(29))||sample.sampleDate().isAfter(end) + for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) { + if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end) ||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); } List samples=new ArrayList<>(); - for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); + for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)); var c=snapshot.context(); var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), - c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began); + c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began); if(supplied!=null&&!supplied.equals(evidence)) throw refresh(); return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c, c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot); @@ -68,6 +68,12 @@ public class InventoryTurnoverContext { private String encode(Evidence evidence) { try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);} } + private String samplesHash(List samples) { + // Reconstruct the entire ordered calendar at asOf before comparing its digest. + // Keep the GET pagination token bounded even for a full year of samples. + try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(samples)));} + catch(Exception e){throw new IllegalStateException("Cannot encode turnover samples",e);} + } private static LocalDateTime utc(Instant value){return LocalDateTime.ofInstant(value,ZoneOffset.UTC);} private static BizException refresh(){return new BizException("库存周转上下文无效或已过期,请整体刷新");} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java index b0a83256e..e213d7485 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java @@ -21,9 +21,9 @@ public final class InventoryTurnoverPolicy { static String text(Map row,String key){Object value=row.get(key);return value==null?null:value.toString();} static BigDecimal divide(BigDecimal a,int n){return a==null?null:a.divide(BigDecimal.valueOf(n),6,RoundingMode.HALF_UP);} private static BigDecimal scale(BigDecimal value){return value==null?null:value.setScale(6,RoundingMode.HALF_UP);} - static List windows(Map row,LocalDate until) { + static List windows(Map row,LocalDate until, java.util.List sizes) { List result=new ArrayList<>(); - for(int n:List.of(7,15,30)) { + for(int n:sizes) { String key="w"+n+"_";var formal=decimal(row,key+"outbound");var observed=decimal(row,key+"observed"); int complete=count(row,key+"complete"),partial=count(row,key+"partial"); result.add(new Window(n,until.minusDays(n),until,formal,observed,divide(formal,n),complete,partial, @@ -48,7 +48,7 @@ public final class InventoryTurnoverPolicy { static Row row(Map raw,InventoryTurnoverRequest q,Context context) { var r=normalized(raw);boolean owner="OWNER".equals(q.group()); Total physical=total(r,"physical",owner),available=total(r,"available",owner); - var windows=windows(r,context.endDate().plusDays(1));var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow(); + var windows=windows(r,context.endDate().plusDays(1),q.windows());var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow(); var coverage=owner?null:scale(decimal(r,"coverage_days"));var rawAverage=decimal(r,"average_physical");var average=scale(rawAverage); var turnover=scale(decimal(r,"turnover"));int sampleCount=count(r,"sample_count"); String coverageStatus=owner?"NOT_APPLICABLE":available.value()==null?"INVENTORY_UNKNOWN_OR_STALE":available.value().signum()==0?"NO_AVAILABLE_STOCK": @@ -59,7 +59,7 @@ public final class InventoryTurnoverPolicy { List missing=new ArrayList<>();String dates=text(r,"missing_dates"); if(dates!=null&&!dates.isBlank())for(String date:dates.split(","))missing.add(LocalDate.parse(date.substring(0,10))); List attention=new ArrayList<>(); - if(count(r,owner?"no_outbound_count":"no_outbound")>0)attention.add("NO_OUTBOUND_30D"); + if(count(r,owner?"no_outbound_count":"no_outbound")>0)attention.add("NO_OUTBOUND_IN_WINDOW"); if(count(r,owner?"slow_count":"slow")>0)attention.add("COVERAGE_GT_THRESHOLD"); if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK"); if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java index a1140f22d..40a5e2c22 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java @@ -38,7 +38,7 @@ public class InventoryTurnoverQueryService { } if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section"); if(request.overview()) { - charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1))); + charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1),request.windows())); } return new Response(context,summary,charts,List.copyOf(rows),total); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java index e8fb8e892..885658381 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -17,6 +17,7 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); } public int days(){return baseWindowDays==null?30:baseWindowDays;} + public java.util.List windows(){return java.util.stream.Stream.of(7,15,30,days()).distinct().sorted().toList();} public String group(){return groupBy==null?"SKU":groupBy;} public String attention(){return focus==null?"ALL":focus;} public String sort(){return sortBy==null?"PHYSICAL_DESC":sortBy;} @@ -29,11 +30,11 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库"); if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主"); if(sku!=null) AnalyticsQuery.text(sku,128,"SKU"); - if(!Set.of(7,15,30).contains(days())) throw new BizException("基准窗口仅支持7、15、30日"); + if(days()<1||days()>366) throw new BizException("统计区间须为1至366个完整自然日"); if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期"); if(!Set.of("SKU","OWNER").contains(group())) throw new BizException("分组仅支持SKU或OWNER"); if("OWNER".equals(group())&&sku!=null) throw new BizException("货主汇总不接受SKU筛选"); - if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT","COVERAGE_LE","FROZEN","DEFECTIVE","UNASSESSABLE").contains(attention())) + if(!Set.of("ALL","NO_OUTBOUND_IN_WINDOW","COVERAGE_GT","COVERAGE_LE","FROZEN","DEFECTIVE","UNASSESSABLE").contains(attention())) throw new BizException("库存关注条件无效"); if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0 || attention().startsWith("COVERAGE_")&&coverageDaysThreshold==null) throw new BizException("可售天数阈值须为正整数"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java index 0f4086921..b850b5989 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java @@ -18,7 +18,7 @@ public final class InventoryTurnoverResponses { BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity, BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays, List missingSampleDates,List sampleReasonCodes,LocalDate lastObservedOutboundDate, - List attentionReasons,Risk noOutbound30d,Risk slowCoverage,int unassessableSkuCount) {} + List attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount) {} public record Sample(LocalDate date,Long runId) {} public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate, int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 25eacdbbd..7df9cc1be 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -9,12 +9,13 @@ public final class InventoryTurnoverSql { public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {} public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh, boolean salesKnown,List days) { - public LocalDate until(){return days.get(29).date().plusDays(1);} + public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);} public LocalDate from(){return days.get(0).date();} + public LocalDate fromBase(){return baseFrom();} public LocalDate baseFrom(){return until().minusDays(query.days());} public LocalDate from7(){return until().minusDays(7);} public LocalDate from15(){return until().minusDays(15);} - public LocalDate from30(){return from();} + public LocalDate from30(){return until().minusDays(30);} public LocalDateTime baseFromUtc(){return baseFrom().atStartOfDay().minusHours(8);} public LocalDateTime fromUtc(){return from().atStartOfDay().minusHours(8);} } @@ -34,6 +35,7 @@ public final class InventoryTurnoverSql { return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(), "VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days); } + private static String windowStart(int n){return "#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";} private static String scope(String alias) { return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}" +" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})"; @@ -73,33 +75,33 @@ public final class InventoryTurnoverSql { .append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,") .append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ") .append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)") - .append(skuWindows()).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") + .append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ") .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid"); - for(int n:List.of(7,15,30)) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); + for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix); sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound"); for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field); sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ") .append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,") .append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 AND evidence<=#{p.baseFromUtc} THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,") .append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,") - .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w30_outbound IS NOT NULL AND evidence<=#{p.fromUtc} THEN CASE WHEN w30_outbound=0 THEN 1 ELSE 0 END END no_outbound,") + .append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL AND evidence<=#{p.baseFromUtc} THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,") .append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL OR evidence IS NULL OR evidence>#{p.baseFromUtc} THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (") .append("SELECT b.*,CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w") .append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) "); return sql.toString(); } - private static String skuWindows() { + private static String skuWindows(Input p) { // One shared suffix table qualifies absent sales dates. Millisecond evidence // after a midnight selects the next midnight, never the truncated same date. var sql=new StringBuilder(", evidence_boundaries AS (SELECT start_utc,LAG(start_utc) OVER (ORDER BY start_utc) previous_start"); - for(int n:List.of(7,15,30)) for(String kind:List.of("complete","known","partial")) { + for(int n:p.query().windows()) for(String kind:List.of("complete","known","partial")) { String coverage=switch(kind){case "complete"->"coverage=1";case "known"->"coverage>0";default->"coverage=2";}; - sql.append(",SUM(CASE WHEN business_date>=CAST(#{p.from").append(n).append("} AS DATE) AND ").append(coverage) + sql.append(",SUM(CASE WHEN business_date>=CAST(#{p.from").append(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base").append("} AS DATE) AND ").append(coverage) .append(" THEN 1 ELSE 0 END) OVER (ORDER BY start_utc ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) w").append(n).append('_').append(kind); } sql.append(" FROM calendar), sku_zero_counts AS (SELECT c.customer_code,c.sku_code"); - for(int n:List.of(7,15,30)) for(String kind:List.of("complete","known","partial")) + for(int n:p.query().windows()) for(String kind:List.of("complete","known","partial")) sql.append(",CASE WHEN c.unit_valid=1").append("complete".equals(kind)?"":" AND #{p.salesKnown}=1") .append(" THEN COALESCE(b.w").append(n).append('_').append(kind).append(",0) ELSE 0 END w").append(n).append('_').append(kind); sql.append(" FROM profiles c LEFT JOIN evidence_boundaries b ON c.evidence<=b.start_utc AND (b.previous_start IS NULL OR c.evidence>b.previous_start)), actual_sku_days AS (") @@ -109,8 +111,8 @@ public final class InventoryTurnoverSql { .append("CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND d.coverage>0 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_known,") .append("CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND d.coverage=2 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_partial ") .append("FROM sales_days s JOIN profiles c ON c.customer_code=s.customer_code AND c.sku_code=s.sku_code JOIN calendar d ON d.business_date=s.business_date), sku_actual_counts AS (SELECT customer_code,sku_code"); - for(int n:List.of(7,15,30)) { - String range="business_date>=CAST(#{p.from"+n+"} AS DATE)"; + for(int n:p.query().windows()) { + String range="business_date>=CAST("+windowStart(n)+" AS DATE)"; sql.append(",SUM(CASE WHEN ").append(range).append(" THEN COALESCE(observed,0) ELSE 0 END) w").append(n).append("_sum") .append(",SUM(CASE WHEN ").append(range).append(" THEN complete-zero_complete ELSE 0 END) w").append(n).append("_complete") .append(",SUM(CASE WHEN ").append(range).append(" THEN (CASE WHEN observed IS NOT NULL THEN 1 ELSE 0 END)-zero_known ELSE 0 END) w").append(n).append("_known") @@ -126,40 +128,40 @@ public final class InventoryTurnoverSql { // Keep the qualified sum exact; do not divide again by a rounded daily mean. .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,") .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") - .append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.sample_total,s.average_physical,COALESCE(s.sample_mask,0) sample_mask"); - for(int n:List.of(7,15,30)) sql.append(",CASE WHEN z.w").append(n).append("_known+COALESCE(a.w").append(n).append("_known,0)>0 THEN COALESCE(a.w").append(n).append("_sum,0) END w").append(n).append("_observed") + .append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.sample_total,s.average_physical,COALESCE(s.sample_mask,'') sample_mask"); + for(int n:p.query().windows()) sql.append(",CASE WHEN z.w").append(n).append("_known+COALESCE(a.w").append(n).append("_known,0)>0 THEN COALESCE(a.w").append(n).append("_sum,0) END w").append(n).append("_observed") .append(",z.w").append(n).append("_complete+COALESCE(a.w").append(n).append("_complete,0) w").append(n).append("_complete") .append(",z.w").append(n).append("_partial+COALESCE(a.w").append(n).append("_partial,0) w").append(n).append("_partial"); return sql.append(" FROM sku_zero_counts z LEFT JOIN sku_actual_counts a ON a.customer_code=z.customer_code AND a.sku_code=z.sku_code ") .append("LEFT JOIN sku_sample_counts s ON s.customer_code=z.customer_code AND s.sku_code=z.sku_code), sku_windows AS (SELECT w.*") - .append(formalWindows()).append(" FROM sku_window_counts w)").toString(); + .append(formalWindows(p)).append(" FROM sku_window_counts w)").toString(); } private static String sampleMask() { - // Exactly 30 bits, all exactly representable even by POWER's numeric result. - return "SUM(CASE WHEN sample_physical IS NOT NULL THEN CAST(POWER(2,TIMESTAMPDIFF(DAY,CAST(#{p.from} AS DATE),business_date)) AS SIGNED) ELSE 0 END)"; + // Qualified date membership supports the full selected window without numeric bit overflow. + return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')"; } private static String pageMissingDates() { // Only the selected page expands dates. Numeric qualification and filtering // already used every candidate and every frozen sample before pagination. return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d " - +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND MOD(FLOOR(r.sample_mask/POWER(2,TIMESTAMPDIFF(DAY,CAST(#{p.from} AS DATE),d.business_date))),2)=0) missing_dates FROM page_result r)"; + +"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates FROM page_result r)"; } - private static String windowAggregates() { + private static String windowAggregates(Input p) { StringBuilder sql=new StringBuilder(); - for(int n:List.of(7,15,30)) { - String range="business_date>=CAST(#{p.from"+n+"} AS DATE)"; + for(int n:p.query().windows()) { + String range="business_date>=CAST("+windowStart(n)+" AS DATE)"; sql.append(",SUM(CASE WHEN ").append(range).append(" THEN observed END) w").append(n).append("_observed") .append(",SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END) w").append(n).append("_complete") .append(",SUM(CASE WHEN ").append(range).append(" AND complete=0 AND observed IS NOT NULL THEN 1 ELSE 0 END) w").append(n).append("_partial"); } return sql.toString(); } - private static String formalWindows() { + private static String formalWindows(Input p) { // Derive formal values once per SKU/owner, reusing the same complete count and // observed sum. A complete all-null observation sum retains the prior zero; // partial/unknown windows remain null and never shorten the denominator. var sql=new StringBuilder(); - for(int n:List.of(7,15,30)) sql.append(",CASE WHEN w.w").append(n).append("_complete=").append(n) + for(int n:p.query().windows()) sql.append(",CASE WHEN w.w").append(n).append("_complete=").append(n) .append(" THEN COALESCE(w.w").append(n).append("_observed,0) END w").append(n).append("_outbound"); return sql.toString(); } @@ -176,13 +178,13 @@ public final class InventoryTurnoverSql { +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN a.valid_count>0 THEN a.observed WHEN d.coverage>0 AND c.candidate_count>COALESCE(a.actual_count,0) THEN 0 END END observed," +"s.sample_physical FROM owner_candidates c CROSS JOIN calendar d LEFT JOIN owner_actual_days a ON a.customer_code=c.customer_code AND a.business_date=d.business_date " +"LEFT JOIN owner_sample_days s ON s.customer_code=c.customer_code AND s.business_date=d.business_date), owner_window_counts AS (SELECT customer_code" - +windowAggregates()+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," + +windowAggregates(p)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total," +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical," - +sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows()+" FROM owner_window_counts w), owner_totals AS (" + +sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows(p)+" FROM owner_window_counts w), owner_totals AS (" +totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.sample_mask," +"CASE WHEN w.sample_total>0 THEN w.w"+p.query.days()+"_outbound*#{p.query.days}/w.sample_total END turnover," - +String.join(",",List.of(7,15,30).stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList()) + +String.join(",",p.query().windows().stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList()) +" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) "; } private static String totals(String key,boolean withFocus) { @@ -196,7 +198,7 @@ public final class InventoryTurnoverSql { } private static String filter(Input p,boolean owner) { String predicate=switch(p.query.attention()) { - case "NO_OUTBOUND_30D" -> owner?"no_outbound_count>0":"no_outbound=1"; + case "NO_OUTBOUND_IN_WINDOW" -> owner?"no_outbound_count>0":"no_outbound=1"; case "COVERAGE_GT" -> owner?"slow_count>0":"slow=1"; case "COVERAGE_LE" -> owner?"fast_count>0":"available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold}"; case "FROZEN" -> owner?"frozen_count>0":"frozen>0"; @@ -247,7 +249,7 @@ public final class InventoryTurnoverSql { Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays()+"), warehouse_days AS (SELECT d.business_date," +"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete," +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed " - +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates()+" FROM warehouse_days) SELECT w.*"+formalWindows()+" FROM warehouse_window_counts w"; + +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates(p)+" FROM warehouse_days) SELECT w.*"+formalWindows(p)+" FROM warehouse_window_counts w"; } private static String jsonFields(List fields) { return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")"; @@ -257,7 +259,7 @@ public final class InventoryTurnoverSql { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview(); String source=owner?"owner_result":"sku_result"; List fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable")); - for(int n:List.of(7,15,30))for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); + for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); if(owner) { fields.add("physical_skus"); for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix); @@ -270,7 +272,7 @@ public final class InventoryTurnoverSql { if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"; // Statement-local only: share intermediate relations across report branches; // do not change connection/global settings or any result/filter semantics. - sql+=" SELECT /*+ SET_VAR(optimizer_switch='derived_merge=off') SET_VAR(optimizer_search_depth=4) */ 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_missing" + sql+=" SELECT /*+ SET_VAR(group_concat_max_len=16384) SET_VAR(optimizer_switch='derived_merge=off') SET_VAR(optimizer_search_depth=4) */ 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_missing" +" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner); if(overview) { List summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown")); 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 index 9dd6d3928..080783fee 100644 --- 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 @@ -12,7 +12,7 @@ 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天"); + if(days<1||days>366)throw new BizException("库存销量窗口须为1至366个完整自然日"); LocalDate end=o.salesWindowEndDate()==null?anchor.minusDays(1):o.salesWindowEndDate(); if(!end.isBefore(anchor))throw new BizException("库存销量截止日必须早于查询当日"); LocalDate until=end.plusDays(1),from=until.minusDays(days);var zone=ZoneId.of("Asia/Shanghai"); @@ -20,18 +20,17 @@ final class InventoryWindowPolicy { } 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) { + boolean complete,boolean unit,int days) { if(days<1||days>366)throw new IllegalArgumentException("Inventory window must be 1 to 366 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 + BigDecimal average=unit&&trusted&&complete&&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/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesSort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesSort.java new file mode 100644 index 000000000..267b1a636 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesSort.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +/** Whitelisted global quantity ordering; unknown quantities always follow known values. */ +public enum SalesSort { + QUANTITY_ASC, QUANTITY_DESC; + public String sql(String column) { + return column + " IS NULL," + column + (this == QUANTITY_ASC ? " ASC" : " DESC") + ",sku_code ASC,customer_code ASC"; + } +} 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 2ec0723fb..857a13275 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 @@ -26,7 +26,7 @@ paths: summary: 单仓固定版本经营总览 description: >- 平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。 - 默认LAST_15_DAYS统一用于趋势、区间出库、日均、互斥分桶及三个Top10;热销按已采集有效量排序并标记覆盖状态,库存评估要求所选区间完整销量及观察资格。 + 默认LAST_15_DAYS统一用于趋势、区间出库、日均、互斥分桶及三个Top10;热销按已采集有效量排序并标记覆盖状态,库存评估要求所选区间完整可信销量和有效库存;商品观察期仅提示。 不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。 库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。 parameters: @@ -57,7 +57,7 @@ paths: /api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary: get: summary: SKU五周期或历史累计清单 - description: '候选为两版profile并集,SQL按选定日期聚合后按observedQuantity降序空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。' + description: '候选为两版profile并集,SQL按选定日期聚合后按salesSort指定observedQuantity升降序(默认降序)空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。' parameters: - {$ref: '#/components/parameters/sourceAccount'} - {$ref: '#/components/parameters/warehouseCode'} @@ -74,6 +74,11 @@ paths: - {$ref: '#/components/parameters/bucket'} - {$ref: '#/components/parameters/current'} - {$ref: '#/components/parameters/size'} + - name: salesSort + in: query + required: false + description: 全量数量排序,默认降序;未知值始终末尾,同值按SKU及货主升序。 + schema: {type: string, enum: [QUANTITY_ASC, QUANTITY_DESC], default: QUANTITY_DESC} responses: '200': description: '统一ApiResponse,业务失败HTTP200且code非200。' @@ -153,8 +158,8 @@ paths: '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/slow-skus: get: - summary: 固定完整30日有库存低出库Top100 - description: '固定30天,不接受windowDays或rangePreset。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。' + summary: 所选完整时段有库存低出库Top100 + description: '支持CUSTOM起止日期,1至366天,前端统一为截至昨日的3/7/15/30天或自定义;不接受windowDays。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。' parameters: - {$ref: '#/components/parameters/sourceAccount'} - {$ref: '#/components/parameters/warehouseCode'} @@ -165,6 +170,9 @@ paths: - {$ref: '#/components/parameters/salesScopeHash'} - {$ref: '#/components/parameters/inventoryScopeHash'} - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} - {$ref: '#/components/parameters/current'} - {$ref: '#/components/parameters/size'} responses: @@ -183,7 +191,7 @@ paths: /api/platform/v1/last-leg/self-operated-analytics/inventory-coverage: get: summary: 正品库存与所选窗口可售天数 - description: '普通库存明细使用10或30天;总览严格下钻须携带drilldownFrom=DASHBOARD及原图CUSTOM起止日期,不得同时携带salesWindowDays/salesWindowEndDate。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' + description: '普通库存明细使用1至366个完整自然日,由最近3/7/15/30天或自定义区间计算;总览严格下钻须携带drilldownFrom=DASHBOARD及原图CUSTOM起止日期,不得同时携带salesWindowDays/salesWindowEndDate。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位及完整可信窗口;观察期仅提示,不阻断统计;零销量天数为空。 权限platform:self-operated-analytics:list。' parameters: - {$ref: '#/components/parameters/rangePreset'} - {$ref: '#/components/parameters/startDate'} @@ -195,7 +203,7 @@ 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: salesWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}, description: 仅库存明细使用的完整自然日窗口} - {name: salesWindowEndDate, in: query, schema: {type: string, format: date}, description: 含当日的窗口截止日,默认固定查询基准的北京时间昨日;必须早于查询当日,分页须携带原窗口} - {$ref: '#/components/parameters/sourceAccount'} @@ -393,7 +401,7 @@ components: drilldownFrom: {name: drilldownFrom, in: query, description: 必须固定原版本,服务端在真实库存有效期与查询到期较早者阻断并要求刷新总览;不增加权限, schema: {type: string, enum: [DASHBOARD]}} keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页} attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}} - sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序} + sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_ASC, AVAILABLE_DESC, COVERAGE_DAYS_ASC, COVERAGE_DAYS_DESC, PHYSICAL_ASC, PHYSICAL_DESC, FROZEN_ASC, FROZEN_DESC, DEFECTIVE_ASC, DEFECTIVE_DESC, TRANSIT_ASC, TRANSIT_DESC, OUTBOUND_ASC, OUTBOUND_DESC, AVERAGE_DAILY_ASC, AVERAGE_DAILY_DESC]}, description: 完整候选先筛选后按正式未舍入值排序分页,未知末尾,同值SKU和货主升序;出库和日均按本次实际窗口,已采集小计不参与正式值排序} coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策} sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} @@ -658,16 +666,17 @@ components: status: {type: string, nullable: true} reasonCodes: {type: array, items: {type: string}} InventoryMetric: - description: 库存窗口响应。windowOutboundQuantity为所选完整窗口销量:普通明细10或30日,总览严格下钻使用原图CUSTOM区间;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。无出库关注、出库排序及分组均使用实际窗口。 + description: 库存窗口响应。windowOutboundQuantity为所选完整窗口销量:普通明细10或30日,总览严格下钻使用原图CUSTOM区间;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。无出库关注、出库排序及分组均使用实际窗口;商品观察期仅提示,不阻断完整窗口销量及可售天数。完整可信窗口中无出库行可记零,覆盖不足或数量异常不可补零。 type: object properties: customerName: {type: string, nullable: true} defectivePhysicalQuantity: {type: number, nullable: true} windowOutboundQuantity: {type: number, nullable: true} + observedWindowOutboundQuantity: {type: number, nullable: true, description: 同一固定销量版及实际窗口中单位有效的已采集出库小计;不代替完整窗口出库,不用于推算日均或可售天数,未知不补零} 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]}} + quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE, UNIT_UNKNOWN_OR_CONFLICT, SALES_UNTRUSTED, COVERAGE_INSUFFICIENT]}} sourceAccount: {type: string, nullable: true} customerCode: {type: string, nullable: true} warehouseCode: {type: string, nullable: true} @@ -684,7 +693,7 @@ components: inventoryCoverageDays: {type: number, nullable: true} inventoryQuality: {type: string, nullable: true} salesCoverage: {type: string, nullable: true} - observationEligible: {type: boolean, nullable: true} + observationEligible: {type: boolean, nullable: true, description: 是否已有不晚于窗口起点的可靠商品记录;false仅用于观察提示,不阻断完整可信窗口出库、日均和可售天数} earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true} status: {type: string, nullable: true} reasonCodes: {type: array, items: {type: string}} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index 01cffcabd..464a4fdd1 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -5,10 +5,10 @@ info: description: >- 仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。 仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。 - 三窗为截至endDate的7/15/30个完整北京时间自然日;当前库存独立固定版本。 + 所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。 每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据, 不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、 - 重建状态及全部30日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 + 重建状态及全部max(30,baseWindowDays)日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。 上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。 历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。 @@ -28,13 +28,13 @@ paths: - {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} - {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} - {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}} - - {name: baseWindowDays, in: query, schema: {type: integer, enum: [7, 15, 30], default: 30}} + - {name: baseWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}} - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} - {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} - name: focus in: query - schema: {type: string, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} + schema: {type: string, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} - {name: coverageDaysThreshold, in: query, description: 'GT/LE必填,其余可选;无默认业务阈值', schema: {type: integer, minimum: 1}} - {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}} - name: sortBy @@ -91,7 +91,7 @@ components: type: object required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes] properties: - days: {type: integer, enum: [7, 15, 30]} + days: {type: integer, minimum: 1, maximum: 366} startDate: {type: string, format: date} endDateExclusive: {type: string, format: date} outboundQuantity: {$ref: '#/components/schemas/Quantity'} @@ -110,7 +110,7 @@ components: sourceAccount: {type: string} warehouseCode: {type: string} endDate: {type: string, format: date} - baseWindowDays: {type: integer, enum: [7, 15, 30]} + baseWindowDays: {type: integer, minimum: 1, maximum: 366} queryAsOf: {type: string, format: date-time} queryExpiresAt: {type: string, format: date-time} analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'} @@ -120,7 +120,7 @@ components: dailySamples: type: array minItems: 30 - maxItems: 30 + maxItems: 366 items: type: object required: [date, runId] @@ -131,7 +131,7 @@ components: accumulationStartedOn: {type: string, format: date, nullable: true} Row: type: object - required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutbound30d, slowCoverage, unassessableSkuCount] + required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutboundInWindow, slowCoverage, unassessableSkuCount] properties: customerCode: {type: string} customerName: {type: string, nullable: true} @@ -142,7 +142,7 @@ components: frozenQuantity: {$ref: '#/components/schemas/Total'} defectiveQuantity: {$ref: '#/components/schemas/Total'} physicalSkuCount: {$ref: '#/components/schemas/Quantity'} - windows: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}} + windows: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/Window'}} averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'} inventoryCoverageDays: {$ref: '#/components/schemas/Quantity'} coverageStatus: {type: string, enum: [VALID, NOT_APPLICABLE, INVENTORY_UNKNOWN_OR_STALE, NO_AVAILABLE_STOCK, UNASSESSABLE, NO_OUTBOUND_IN_WINDOW]} @@ -150,12 +150,12 @@ components: quantityTurnoverCount: {$ref: '#/components/schemas/Quantity'} turnoverStatus: {type: string, enum: [VALID, INSUFFICIENT, ZERO_AVERAGE_STOCK, SALES_WINDOW_UNAVAILABLE]} qualifiedSampleDays: {type: integer, minimum: 0, maximum: 30} - requiredSampleDays: {type: integer, enum: [7, 15, 30]} + requiredSampleDays: {type: integer, minimum: 1, maximum: 366} missingSampleDates: {type: array, items: {type: string, format: date}} sampleReasonCodes: {$ref: '#/components/schemas/Reasons'} lastObservedOutboundDate: {type: string, format: date, nullable: true} attentionReasons: {$ref: '#/components/schemas/Reasons'} - noOutbound30d: {$ref: '#/components/schemas/Risk'} + noOutboundInWindow: {$ref: '#/components/schemas/Risk'} slowCoverage: {$ref: '#/components/schemas/Risk'} unassessableSkuCount: {type: integer, minimum: 0} Overview: @@ -183,7 +183,7 @@ components: customerName: {type: string, nullable: true} physicalQuantity: {$ref: '#/components/schemas/Quantity'} unknownOwnerCount: {type: integer, minimum: 0} - averageDaily: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}} + averageDaily: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/Window'}} Response: type: object required: [context, summary, charts, rows, total] 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 972788348..bffb5c563 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 @@ -41,9 +41,9 @@ class PlatformAnalyticsControllerTest { @Test void allElevenReadEndpointsShareTheIndependentGuard(){ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform(); - c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null); + c.salesSummary(user,null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null); c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null); - verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null); + verify(service).salesSummary(null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);verify(service).dailySales(null);verify(service).hotSkus(null); verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null); verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null); assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformAnalyticsController.class.getAnnotation(PreAuthorize.class).value()); @@ -62,10 +62,19 @@ class PlatformAnalyticsControllerTest { .param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22") .param("salesScopeHash","a".repeat(64)).param("inventoryScopeHash","b".repeat(64)) .param("queryAsOf","2026-10-12T04:00:00Z")).andExpect(status().isOk()); - var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture()); + var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture(),eq(com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC)); assertEquals(LocalDate.of(2026,10,1),cap.getValue().startDate());assertEquals(Instant.parse("2026-10-12T04:00:00Z"),cap.getValue().queryAsOf()); assertEquals(11L,cap.getValue().salesRunId());assertEquals(2,cap.getValue().current()); } + @Test void salesSortBindsAscendingAndRejectsUnknownValues() throws Exception { + var service=mock(AnalyticsQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,mock(CollectionQueryService.class),mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary").param("salesSort","QUANTITY_ASC")).andExpect(status().isOk()); + verify(service).salesSummary(any(),eq(com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_ASC)); + clearInvocations(service); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary").param("salesSort","invalid")).andExpect(status().isBadRequest()); + verifyNoInteractions(service); + } @Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build(); 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 59c35921b..257c65aa8 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 @@ -93,6 +93,7 @@ class AnalyticsQueryServiceTest { when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); when(mapper.count(any())).thenReturn(1L); when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40))))); + when(mapper.salesPage(any(),any())).thenAnswer(invocation -> mapper.page(invocation.getArgument(0))); when(mapper.inventoryCount(any(),any())).thenReturn(1L); doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).completeWindow()?"150":null))).when(mapper).inventoryPage(any(),any()); when(mapper.rankingStats(any())).thenReturn(List.of(new AnalyticsReadMapper.RankingStat(1L,null,0L))); @@ -108,9 +109,21 @@ class AnalyticsQueryServiceTest { assertFalse(cap.getValue().completeWindow());assertEquals(D.minusDays(44),result.context().rangeStartDate()); assertThrows(com.baiyue.common.core.exception.BizException.class,()->new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(2),D,null,7,1,20,null,null,null,null,null).validate(false,false,true)); } + @Test void customSlowRangeUsesSelectedDaysAndPreservesIncompleteLongWindow(){ + for(int days:List.of(3,45)){ + clearInvocations(mapper); + var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(days),D.minusDays(1),null,null,1,20,null,null,null,null,null); + var result=service.slowSkus(q); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture()); + assertEquals(D.minusDays(days),cap.getValue().from());assertEquals(D,cap.getValue().until()); + assertEquals(days,result.context().calculationWindowDays()); + if(days==3)assertEquals(0,n("50").compareTo(result.records().get(0).averageDailyOutboundQuantity())); + else assertNull(result.records().get(0).averageDailyOutboundQuantity()); + } + } @Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){ when(mapper.count(any())).thenReturn(240L); - var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null)); + var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).count(cap.capture()); verify(mapper).page(cap.capture()); assertEquals(cap.getAllValues().get(0),cap.getAllValues().get(1)); @@ -134,7 +147,7 @@ class AnalyticsQueryServiceTest { assertEquals(1,response.summary().partialDayCount()); assertEquals("PCS",response.summary().quantityUnit()); verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1)); verify(mapper,never()).page(any()); verify(mapper,never()).count(any()); - var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU")).records().get(0); + var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU"),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC).records().get(0); assertEquals(response.summary().observedQuantity(),summary.observedQuantity()); assertEquals(response.summary().outboundQuantity(),summary.outboundQuantity()); assertEquals(response.summary().dataStatus(),summary.dataStatus()); } @@ -203,9 +216,9 @@ class AnalyticsQueryServiceTest { } @Test void pinnedDateSurvivesMidnightAndNewPublishedPointers(){ Instant before=Instant.parse("2026-10-12T15:55:00Z"),after=before.plusSeconds(600); - var first=at(before).salesSummary(chart("TODAY",null,null,null,null)); + var first=at(before).salesSummary(chart("TODAY",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,33L),scope(2,"INVENTORY",IH,44L))); - var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2)); + var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); assertEquals(D,second.context().anchorDate()); assertEquals(D,second.context().rangeStartDate()); assertEquals(D.plusDays(1),second.context().rangeEndDateExclusive()); assertEquals(11L,second.context().salesRunId()); verify(mapper,never()).publishedRun(33); verify(mapper,never()).publishedRun(44); @@ -213,9 +226,9 @@ class AnalyticsQueryServiceTest { } @Test void missingVersionZeroStaysMissingAfterNewPointerAppears(){ when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,0L),scope(2,"INVENTORY",IH,0L))); - var first=service.salesSummary(chart("TODAY",null,null,null,null)); assertEquals(0L,first.context().salesRunId()); + var first=service.salesSummary(chart("TODAY",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); assertEquals(0L,first.context().salesRunId()); when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); - var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2)); + var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); assertEquals(0L,second.context().inventoryRunId()); assertEquals(0L,second.context().salesRunId()); verify(mapper,never()).publishedRun(anyLong()); var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture()); assertEquals(0,cap.getValue().salesRun()); assertEquals(0,cap.getValue().inventoryRun()); assertFalse(cap.getValue().salesKnown()); @@ -280,11 +293,11 @@ class AnalyticsQueryServiceTest { var row=service.inventoryCoverage(metrics(null),options()).records().get(0); assertNull(row.inventoryCoverageDays());assertNull(row.availableQuantity());assertEquals("UNKNOWN",row.inventoryQuality()); } - @Test void noObservationEvidenceCannotBecomeThirtyDayDenominatorAndNoSalesIsNotInfinity(){ + @Test void unknownSalesStayUnknownAndConfirmedZeroNeedsNoObservationAge(){ doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any()); var unknown=service.inventoryCoverage(metrics(null),options()).records().get(0); - assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("OBSERVATION_INSUFFICIENT",unknown.status()); - doReturn(List.of(inventory(true,"300",true,time(D.minusDays(40)),"0"))).when(mapper).inventoryPage(any(),any()); + assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("SALES_UNKNOWN",unknown.status()); + doReturn(List.of(inventory(true,"300",true,null,"0"))).when(mapper).inventoryPage(any(),any()); var zero=service.inventoryCoverage(metrics(null),options()).records().get(0); assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum()); } @@ -363,6 +376,22 @@ class AnalyticsQueryServiceTest { // Normal inventory diagnostics still return the same fixed but stale version. assertEquals("STALE",service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1),options()).context().inventoryStatus()); } + @Test void completeInventorySalesCalculateWithoutObservationAgeAndRetainTheWarning(){ + var base=inventory(true,"300",true,time(D.minusDays(10)),"92"); + var partial=new Inventory(base.customerCode(),base.skuCode(),base.productName(),true,base.evidenceAtUtc(),base.physicalQuantity(),base.availableQuantity(), + base.frozenQuantity(),base.transitQuantity(),base.defectivePhysicalQuantity(),base.observedAtUtc(),n("92"),n("92"),base.orderCount(),base.lastOutbound(),false,n("92"),n("97.8"),0,0,false); + doReturn(List.of(partial)).when(mapper).inventoryPage(any(),any()); + var row=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertEquals(n("92"),row.observedWindowOutboundQuantity());assertEquals(n("92"),row.windowOutboundQuantity()); + assertEquals(n("3.066666666667"),row.averageDailyOutboundQuantity());assertEquals(n("97.8"),row.inventoryCoverageDays()); + assertEquals("VALID",row.status());assertEquals("VALID",row.quantityStatuses().get("windowOutboundQuantity")); + assertFalse(row.observationEligible());assertTrue(row.reasonCodes().contains("OBSERVATION_INSUFFICIENT"));assertFalse(row.attentionReasons().contains("UNASSESSABLE")); + var json=new ObjectMapper().findAndRegisterModules().valueToTree(row); + assertEquals(92,json.get("observedWindowOutboundQuantity").intValue()); + // Unknown evidence must stay unknown; it is never converted into a collected zero. + doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any()); + assertNull(service.inventoryCoverage(metrics(null),options()).records().get(0).observedWindowOutboundQuantity()); + } private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);} private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){ BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales); 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 f7b696925..7cc649ad1 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 @@ -23,6 +23,15 @@ class AnalyticsReadMapperTest { assertFalse(sql.contains("ll_selfop_outbound_order")); assertFalse(sql.contains("ll_selfop_outbound_item")); assertFalse(sql.contains("ll_selfop_sync_stage")); assertFalse(sql.contains("${")); assertFalse(sql.contains("Exact-SKU")); assertFalse(sql.contains("OWNER")); } + @Test void quantityOrderingIsAppliedBeforePaginationAndKeepsUnknownLast(){ + for(var sort:SalesSort.values()){ + var sql=bound(config(),"salesPage",Map.of("p",request("SUMMARY"),"salesSort",sort)).getSql(); + String direction=sort==SalesSort.QUANTITY_ASC?"ASC":"DESC"; + assertTrue(sql.contains("sort_observed_quantity IS NULL,sort_observed_quantity "+direction)); + assertTrue(sql.contains("ORDER BY observed_quantity IS NULL,observed_quantity "+direction)); + assertTrue(sql.indexOf("sort_observed_quantity "+direction)=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue()); assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known")); } - @Test void ownerSalesCompleteDoesNotRequireEachSkuObservationAndRiskUnknownIsRetained() throws Exception { + @Test void completeWindowSalesAndCoverageDoNotRequireSkuObservationAge() throws Exception { profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null); daily(11,"W","C","NEW",D.minusDays(1),"5"); - var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound()); + var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertDecimal("5",row.outboundQuantity30d());assertEquals(0,row.noOutbound()); + assertFalse(row.observationEligible());assertDecimal("120",row.coverageDays());assertFalse(row.unassessable()); Map totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null,null,null)); assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); - assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical")); + assertEquals(0L,((Number)totals.get("no_outbound_unknown")).longValue()); } @Test void ownersAreCaseSensitiveDistinctAndPrefixWildcardsAreLiteral() throws Exception { for(String owner:List.of("A%","A_test","ABC","abc")){ @@ -327,6 +328,19 @@ public class AnalyticsSqlSemanticTest { assertEquals(0,row.partialDayCount());assertEquals(366,row.unknownDayCount()); } } + @Test void slowCustomRangeCountsOnlySelectedDatesIncludingHistoryBeyondThirtyDays() throws Exception { + profile(11,"C","CUSTOM","VALID_OUTBOUND",utc(D.minusDays(60),0),"PCS"); + profile(22,"C","CUSTOM","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS"); + stock("C","CUSTOM",0,"100"); + daily(11,"W","C","CUSTOM",D.minusDays(40),"30"); + daily(11,"W","C","CUSTOM",D.minusDays(10),"20"); + daily(11,"W","C","CUSTOM",D.minusDays(1),"10"); + var shortRows=page(request("SLOW",D.minusDays(3),D,null,null,utc(D,0),0,20)); + var longRows=page(request("SLOW",D.minusDays(45),D,null,null,utc(D,0),0,20)); + assertEquals(1,shortRows.size());assertEquals(1,longRows.size()); + assertEquals(0,new BigDecimal("10").compareTo(shortRows.get(0).windowQuantity())); + assertEquals(0,new BigDecimal("60").compareTo(longRows.get(0).windowQuantity())); + } @Test void rankingStatisticsPreserveFullEligibilityTopCapExclusionsAndSharedSkuOwners() throws Exception { for(int i=0;i<110;i++){ String sku=String.format("SKU-%03d",i);profile(11,"C",sku,"VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); @@ -386,7 +400,7 @@ public class AnalyticsSqlSemanticTest { var second=inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),2,2),options); assertEquals(List.of("B","NO-SALES"),second.stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); assertEquals(D.minusDays(40),second.get(0).lastOutbound());assertDecimal("0",second.get(0).outboundQuantity30d()); - assertNull(second.get(1).outboundQuantity30d());assertTrue(second.get(1).unassessable()); + assertDecimal("0",second.get(1).outboundQuantity30d());assertFalse(second.get(1).unassessable());assertFalse(second.get(1).observationEligible()); var third=inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),4,2),options); assertEquals(List.of("UNIT","UNKNOWN"),third.stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); assertTrue(third.stream().allMatch(r->r.availableQuantity()==null));assertNull(third.get(0).physicalQuantity());assertDecimal("100",third.get(1).physicalQuantity()); @@ -399,6 +413,24 @@ public class AnalyticsSqlSemanticTest { for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_ASC")) assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged")); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"AVAILABLE","PHYSICAL","FROZEN","DEFECTIVE","TRANSIT","OUTBOUND","AVERAGE_DAILY","COVERAGE_DAYS"}) + void allInventoryNumericSortsKeepUnknownLastAndSortBeforePagination(String metric) throws Exception { + for(String sku:List.of("LOW","LOW-TIE","HIGH","UNKNOWN")) { + String unit=sku.equals("UNKNOWN")?"BOX":"PCS"; + profile(11,"C",sku,"VALID_OUTBOUND",utc(D.minusDays(40),0),unit); + profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(40),0),unit); + boolean high=sku.equals("HIGH"); + stockExact(sku,0,high?"1000":"100",high?"1000":"100",high?"9":"1",high?"90":"10","VALID",null); + stockExact(sku,1,high?"90":"10","0","0","0","VALID",null); + daily(11,"W","C",sku,D.minusDays(1),high?"5":"1"); + } + for(String direction:List.of("ASC","DESC")) { + var expected=direction.equals("ASC")?List.of("LOW","LOW-TIE","HIGH","UNKNOWN"):List.of("HIGH","LOW","LOW-TIE","UNKNOWN"); + assertEquals(expected,inventory(null,null,metric+"_"+direction,0,20).stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); + assertEquals(expected.get(1),inventory(null,null,metric+"_"+direction,1,1).get(0).skuCode()); + } + } @Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception { for(String sku:List.of("HISTORY","NONE","ANCHOR")){ profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS");stock("C",sku,0,"10"); @@ -469,7 +501,8 @@ public class AnalyticsSqlSemanticTest { assertEquals(1,second.size());assertNotEquals("TEN",second.get(0).skuCode()); assertTrue(inventoryRows(request("INVENTORY",D.minusDays(10),D,"C",null,utc(D,8),3,1),o).isEmpty()); var zero=rows.stream().filter(r->r.skuCode().equals("ZERO")).findFirst().orElseThrow();assertDecimal("0",zero.outboundQuantity30d());assertNull(zero.coverageDays()); - assertNull(rows.stream().filter(r->r.skuCode().equals("YOUNG")).findFirst().orElseThrow().outboundQuantity30d()); + var young=rows.stream().filter(r->r.skuCode().equals("YOUNG")).findFirst().orElseThrow(); + assertDecimal("0",young.outboundQuantity30d());assertFalse(young.observationEligible());assertEquals(1,young.noOutbound());assertNull(young.coverageDays()); var filtered=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1)); assertEquals(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue()); assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); 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 ae2fff259..cf7c5bb0e 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 @@ -50,13 +50,27 @@ class DashboardPolicyTest { assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE")); } } - @Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){ + @Test void historicalCoverageStillRequiresKnownSourceAndValidQuantity(){ LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L)); assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows,java.util.List.of()).get(0).dataStatus()); var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L)),java.util.List.of()).get(0); assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());assertTrue(invalid.reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID")); - var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows,java.util.List.of()).get(0); - assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity()); + } + @Test void pendingRebuildPreservesFrozenHistoricalCoverageWithoutFillingGapsOrToday(){ + LocalDate day=DAY.minusDays(3);var c=publishedDayContext("REBUILD_REQUIRED",true); + for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))){ + var rows=amount.signum()==0?List.of():List.of(new DashboardReadMapper.Day(day,amount,0L)); + var points=DashboardSalesPolicy.points(c,true,day,DAY.plusDays(1),rows,List.of()); + assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(amount,points.get(0).outboundQuantity()); + assertTrue(points.get(0).reasonCodes().contains("SALES_REBUILD_REQUIRED")); + assertFalse(points.get(0).reasonCodes().contains("SALES_DAY_INCOMPLETE")); + assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN"),points.stream().map(p->p.dataStatus()).toList()); + assertTrue(points.get(3).reasonCodes().contains("TODAY_INCOMPLETE")); + assertNull(SalesBucketPolicy.aggregate(points).outboundQuantity()); + } + var invalid=DashboardSalesPolicy.points(c,true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,BigDecimal.ONE,1L)),List.of()).get(0); + assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity()); + assertEquals("UNKNOWN",DashboardSalesPolicy.points(c,false,day,day.plusDays(1),List.of(),List.of()).get(0).dataStatus()); } private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){ LocalDate day=DAY.minusDays(3);var zone=ZoneId.of("Asia/Shanghai"); 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 38eb6f4c9..ce841673c 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 @@ -184,6 +184,22 @@ class DashboardSqlSemanticTest { assertEquals(6,count(stats,"no_outbound_eligible")); assertTrue(ranks().stream().anyMatch(row->row.rankCode().equals("NO_OUTBOUND")&&row.skuCode().equals("FROZEN-0"))); } + @Test void newSkuWindowMetricsMatchDashboardAndDrilldownWithoutObservationAge()throws Exception{ + fixture("NEW-SOLD","30","30","15");fixture("NEW-UNSOLD","40","40",null); + try(var statement=connection.createStatement()) { + statement.execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=NULL,evidence_type='NONE'"); + } + var p=windowRequest(D.minusDays(15),D,true,"DASHBOARD"); + var report=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options())); + Map values=new HashMap<>();((Map)report.get(0)).forEach((k,v)->values.put(k.toString().toLowerCase(Locale.ROOT),v)); + assertEquals(1,count(values,"long_eligible"));assertEquals(1,count(values,"no_outbound_eligible"));assertEquals(0,count(values,"bucket_unknown")); + var rows=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",p,"o",options())).stream().map(AnalyticsReadRows.Inventory.class::cast).toList(); + assertEquals(2,rows.size());assertTrue(rows.stream().noneMatch(AnalyticsReadRows.Inventory::observationEligible)); + var sold=rows.stream().filter(row->row.skuCode().equals("NEW-SOLD")).findFirst().orElseThrow(); + assertEquals(0,new BigDecimal("15").compareTo(sold.outboundQuantity30d()));assertEquals(0,new BigDecimal("30").compareTo(sold.coverageDays())); + var zero=rows.stream().filter(row->row.skuCode().equals("NEW-UNSOLD")).findFirst().orElseThrow(); + assertEquals(0,zero.outboundQuantity30d().signum());assertNull(zero.coverageDays());assertEquals(1,zero.noOutbound()); + } @Test void exactNinetyBoundaryAndUnknownInventoryRemainDistinct()throws Exception{ fixture("NINETY","90","90","30");fixture("NINETY04","90.04","90.04","30");fixture("INFINITE","999","999",null); var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_in_window")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java index 8164b0fd5..4e8172201 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -46,6 +46,16 @@ class InventoryTurnoverContextTest { when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); } + @Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() { + var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null); + var first=contexts.resolve(q).response(); + assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size()); + assertTrue(first.snapshotContext().length()<2048); + var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext()); + assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response()); + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); + assertThrows(BizException.class,()->contexts.resolve(next)); + } @Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception { String token=contexts.resolve(request(null)).response().snapshotContext(); var evidence=json.readTree(Base64.getUrlDecoder().decode(token)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java index 477c10cf1..4632dd4be 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java @@ -19,7 +19,7 @@ class InventoryTurnoverRequestTest { assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview()); } @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { - for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",10), + for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",367), Map.of("groupBy","OWNER","sku","S"),Map.of("groupBy","OWNER","sortBy","COVERAGE_DESC"), Map.of("sortBy","NO_OUTBOUND_PHYSICAL_DESC"),Map.of("current",2), Map.of("size",101),Map.of("coverageDaysThreshold",0),Map.of("focus","COVERAGE_GT"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index c08b433df..b309a398d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -31,6 +31,27 @@ class InventoryTurnoverSqlTest { for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i(); + int horizon=Math.max(30,n); + for(int i=0;i relations=new ArrayList<>();int cursor=5; 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 index 54ac47fb0..9324c04dc 100644 --- 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 @@ -21,7 +21,7 @@ class InventoryWindowPolicyTest { assertEquals(DAY.minusDays(30),InventoryWindowPolicy.range(options(null,null),DAY).startDate()); assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY),DAY)); assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY.plusDays(1)),DAY)); - assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(7,null),DAY)); + assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(367,null),DAY)); } @Test void selectedWindowPredicatesWorkForTenDayAndHistoricalWindows() { for(var o:new AnalyticsInventoryOptions[]{ @@ -33,18 +33,18 @@ class InventoryWindowPolicyTest { assertThrows(BizException.class,()->options(10,null).validate("OVERVIEW",null)); } @Test void tenDayFormulaKeepsMissingAndZeroSalesDistinctAndAvoidsIntermediateRounding() { - var valid=InventoryWindowPolicy.calculate("VALID",new BigDecimal("120"),new BigDecimal("30"),true,true,true,true,10); + var valid=InventoryWindowPolicy.calculate("VALID",new BigDecimal("120"),new BigDecimal("30"),true,true,true,10); assertEquals(0,new BigDecimal("3").compareTo(valid.average()));assertEquals(new BigDecimal("40.0"),valid.days()); - var partial=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ONE,true,false,true,true,10); + var partial=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ONE,true,false,true,10); assertNull(partial.average());assertNull(partial.days());assertEquals("COVERAGE_INSUFFICIENT",partial.status()); - var zero=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ZERO,true,true,true,true,10); + var zero=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ZERO,true,true,true,10); assertEquals(0,zero.average().signum());assertNull(zero.days());assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status()); - assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,null,true,true,true,true,10).days()); - assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,false,true,10).days()); - assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,true,false,10).days()); - assertNull(InventoryWindowPolicy.calculate("STALE",BigDecimal.ZERO,BigDecimal.TEN,true,true,true,true,10).days()); - assertEquals(new BigDecimal("0.0"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ZERO,null,false,false,false,false,10).days()); - assertEquals(new BigDecimal("3.3"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ONE,new BigDecimal("3"),true,true,true,true,10).days()); + assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,null,true,true,true,10).days()); + assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,false,true,true,10).days()); + assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,false,10).days()); + assertNull(InventoryWindowPolicy.calculate("STALE",BigDecimal.ZERO,BigDecimal.TEN,true,true,true,10).days()); + assertEquals(new BigDecimal("0.0"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ZERO,null,false,false,false,10).days()); + assertEquals(new BigDecimal("3.3"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ONE,new BigDecimal("3"),true,true,true,10).days()); } @Test void inventoryResponseHasOneNeutralWindowQuantityWithoutThirtyDayAliases() { var names=java.util.Arrays.stream(AnalyticsResponses.InventoryMetric.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList();