From f151e74f5c0dd7b3d5fd7b2028fa4f976e0968cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 18:44:49 +0800 Subject: [PATCH] fix(self-operated): rank hot SKUs for selected dates --- .../query/AnalyticsInventorySql.java | 9 ++++--- .../analytics/query/AnalyticsQuery.java | 2 ++ .../query/AnalyticsQueryService.java | 2 +- .../analytics/query/AnalyticsReadSql.java | 16 +++++------ .../query/DashboardQueryService.java | 27 +++++++++++++++---- .../analytics/query/DashboardReadSql.java | 4 +++ .../analytics/query/DashboardResponses.java | 2 +- .../openapi/self-operated-analytics-v1.yaml | 15 ++++++----- .../query/AnalyticsQueryServiceTest.java | 8 ++++++ .../query/AnalyticsReadMapperTest.java | 5 ++-- .../query/DashboardQueryServiceTest.java | 3 ++- .../query/DashboardSqlSemanticTest.java | 15 +++++++++++ 12 files changed, 80 insertions(+), 28 deletions(-) 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 ad8ade2ea..a96458476 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -20,8 +20,8 @@ public final class AnalyticsInventorySql { static String dashboardCte(AnalyticsSqlRequest p){return cte(p,false,true);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst){return cte(p,stockFirst,false);} private static String cte(AnalyticsSqlRequest p,boolean stockFirst,boolean dashboard){ - String days="INVENTORY".equals(p.kind())?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; - String complete="INVENTORY".equals(p.kind())?"#{p.completeWindow}":"#{p.complete30}"; + String days=("INVENTORY".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30"; + String complete=("INVENTORY".equals(p.kind())||"HOT_DASHBOARD".equals(p.kind()))?"#{p.completeWindow}":"#{p.complete30}"; return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," @@ -36,11 +36,12 @@ public final class AnalyticsInventorySql { +"FROM ll_selfop_inventory_snapshot WHERE "+(dashboard&&!p.stockFresh()?"1=0 /* dashboard unused stock */ AND ":"") +"snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," +(stockFirst?stockPageKeys():"") - +salesCte(p,stockFirst,dashboard&&!p.complete30()) + +salesCte(p,stockFirst,dashboard&&!"HOT_DASHBOARD".equals(p.kind())&&!p.complete30()) +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," - +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND "+complete+"=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," + +("HOT_DASHBOARD".equals(p.kind())?"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN CASE WHEN #{p.completeWindow}=1 THEN COALESCE(s.observed_quantity,0) ELSE s.observed_quantity END END owner_outbound,": + "CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND "+complete+"=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound,") +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count " +"FROM profiles c "+(stockFirst?"JOIN stock_paged pk ON pk.customer_code=c.customer_code AND pk.sku_code=c.sku_code ":"") +"LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code)," 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 31d40d586..361e376ba 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,6 +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(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 04ecd05e1..80a10caac 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 @@ -199,7 +199,7 @@ public class AnalyticsQueryService { LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); SalesRangePolicy.Range range; try{ - range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||(hot&&"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()); 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 0fbf1b2e3..61ef17203 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 @@ -209,18 +209,18 @@ public final class AnalyticsReadSql { return "WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+")," +" metric_sales AS (SELECT customer_code,sku_code," - +"SUM(CASE WHEN business_date>=#{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.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," + +"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>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," +"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity END) today_quantity," - +"MIN(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid," - +"MIN(CASE WHEN business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid," + +"MIN(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid," + +"MIN(CASE WHEN business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid," +"MIN(CASE WHEN business_date=#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) today_valid " +"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_date hotStats=stats;List hotRanked=ranked; + if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){ + var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),fixedFrom, + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD"); + var hotReport=mapper.report(hotRequest,o).stream().map(DashboardQueryService::normalize).toList(); + hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); + } var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(), trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays()); var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList(); @@ -44,10 +51,18 @@ public class DashboardQueryService { Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil); return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,fixedFrom,fixedUntil,30,freshUntil,drilldownUntil),kpis, new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c), - ranking("HOT",stats,ranked,base.salesRun()>0,fixedFrom,fixedUntil,c), + hotRanking(ranking("HOT",hotStats,hotRanked,base.salesRun()>0,c.rangeStartDate(),c.rangeEndDateExclusive(),c),trendSummary.dataStatus()), ranking("LONG",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c), ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c)); } + private static DashboardResponses.Ranking hotRanking(DashboardResponses.Ranking ranking,String windowStatus){ + if("COMPLETE".equals(windowStatus))return ranking; + var items=ranking.items().stream().map(i->new DashboardResponses.Item(i.customerCode(),i.sku(),i.productName(),i.value(),i.unit(),i.windowOutboundQuantity(),i.skuOutboundOrderCount(), + i.physicalQuantity(),i.availableQuantity(),i.frozenQuantity(),i.defectivePhysicalQuantity(),null,null)).toList(); + var reasons=new LinkedHashSet<>(ranking.reasonCodes());reasons.add("SALES_WINDOW_INCOMPLETE"); + return new DashboardResponses.Ranking(ranking.windowStartDate(),ranking.windowEndDateExclusive(),ranking.calculationWindowDays(),ranking.eligibleCount(),ranking.unassessableCount(),items, + items.isEmpty()&&"UNKNOWN".equals(windowStatus)?"UNKNOWN":"PARTIAL",List.copyOf(reasons)); + } private static DashboardResponses.Metric stockMetric(Map s,String knownField,String unknownField,String unit,boolean available,boolean fresh,AnalyticsResponses.Context c){ if(!available)return new DashboardResponses.Metric(null,null,unit,"UNKNOWN",null,List.of("INVENTORY_UNAVAILABLE"),null,null); long unknown=count(s,unknownField);BigDecimal known=decimal(s,knownField); @@ -72,6 +87,7 @@ public class DashboardQueryService { available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of()); } private static DashboardResponses.Ranking ranking(String kind,Map s,List rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c){ + int windowDays=(int)java.time.temporal.ChronoUnit.DAYS.between(from,until); String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";}; List selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator .comparing(DashboardReadMapper.Ranked::mainValue,Comparator.reverseOrder()) @@ -79,12 +95,12 @@ public class DashboardQueryService { .thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList(); var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS", "HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(), - divide30("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d()),r.coverageDays())).toList():List.of(); + divide("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),windowDays),"HOT".equals(kind)?null:r.coverageDays())).toList():List.of(); long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE"; List reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of(); if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS"); - if(available&&"HOT".equals(kind)&&!"VALID".equals(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());} - return new DashboardResponses.Ranking(from,until,30,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons); + if(available&&"HOT".equals(kind)&&!Set.of("VALID","STALE").contains(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());} + return new DashboardResponses.Ranking(from,until,windowDays,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons); } private static Map normalize(Map source){Map result=new HashMap<>();if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));return result;} static DashboardReadMapper.Ranked ranked(Map row){ @@ -95,5 +111,6 @@ public class DashboardQueryService { } private static BigDecimal decimal(Map source,String key){Object value=source.get(key);return value==null?null:value instanceof BigDecimal decimal?decimal:new BigDecimal(value.toString());} private static long count(Map source,String key){BigDecimal value=decimal(source,key);return value==null?0:value.longValueExact();} - private static BigDecimal divide30(BigDecimal value){return value==null?null:value.divide(BigDecimal.valueOf(30),12,RoundingMode.HALF_UP);} + private static BigDecimal divide30(BigDecimal value){return divide(value,30);} + private static BigDecimal divide(BigDecimal value,int days){return value==null||days==0?null:value.divide(BigDecimal.valueOf(days),12,RoundingMode.HALF_UP);} } 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 69143387c..f1634697a 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 @@ -8,6 +8,10 @@ public final class DashboardReadSql { private DashboardReadSql(){} public static String report(Map args){ var p=(AnalyticsSqlRequest)args.get("p"); + if("HOT_DASHBOARD".equals(p.kind()))return AnalyticsInventorySql.typed(AnalyticsInventorySql.dashboardCte(p) + +", hot_statistics AS (SELECT COUNT(CASE WHEN owner_outbound>0 THEN 1 END) hot_eligible,COUNT(CASE WHEN owner_outbound IS NULL THEN 1 END) hot_unknown FROM result)" + +", hot_rank AS (SELECT * FROM result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)" + +", hot_rows AS ("+select("HOT","owner_outbound","hot_rank")+") SELECT s.*,r.* FROM hot_statistics s LEFT JOIN hot_rows r ON 1=1"); // Only dashboard outputs are considered here. Without complete30 all sales-dependent // rankings are empty; observed sales still come from the independent daily query. // Stale stock quantities are already masked to NULL. Keep both profile versions so diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java index 114635903..cc677886d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java @@ -19,7 +19,7 @@ public final class DashboardResponses { public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){} public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount, List buckets,String dataStatus,List reasonCodes,Map unknownReasons){} - public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal outboundQuantity30d, + public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal windowOutboundQuantity, Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity, BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays){} public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount, 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 cae503ab9..f331e206e 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 @@ -13,7 +13,7 @@ info: 默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。 每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。 SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。 - 切换图表不改变完整7/30日榜单及30日日均分母;不触发领星请求。 + 热销榜跟随图表所选日期,以已采集有效出库量排序;覆盖不足标记PARTIAL,不改变30日日均分母;不触发领星请求。 运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。 阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。 total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。 @@ -26,7 +26,7 @@ paths: summary: 单仓固定版本经营总览 description: >- 平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。 - 默认LAST_15_DAYS仅改变趋势及区间出库,日均、互斥分桶和三个Top10固定至昨日30完整日。 + 默认LAST_15_DAYS同时用于趋势、区间出库和热销Top10;热销按已采集有效量排序并标记覆盖状态。日均、互斥分桶及两个库存Top10固定至昨日30完整日。 不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。 库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。 parameters: @@ -120,8 +120,8 @@ paths: '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/hot-skus: get: - summary: 完整7/30日热销Top100 - description: '独立截至queryAsOf昨日的完整窗口,数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。' + summary: 所选时段已采集热销Top100 + description: '支持CUSTOM起止日期(含截止日),与总览所选时段一致;未传日期时仍使用windowDays的7/30日窗口。按已采集有效数量排序,覆盖不足由context及coverageStatus提示,不把未知当零。数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。' parameters: - {$ref: '#/components/parameters/sourceAccount'} - {$ref: '#/components/parameters/warehouseCode'} @@ -133,6 +133,9 @@ paths: - {$ref: '#/components/parameters/inventoryScopeHash'} - {$ref: '#/components/parameters/queryAsOf'} - {$ref: '#/components/parameters/windowDays'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} - {$ref: '#/components/parameters/current'} - {$ref: '#/components/parameters/size'} responses: @@ -476,7 +479,7 @@ components: productName: {type: string, nullable: true} value: {type: number} unit: {type: string, enum: [PCS, DAY]} - outboundQuantity30d: {type: number, nullable: true} + windowOutboundQuantity: {type: number, nullable: true, description: 榜单实际窗口出库量;热销在部分覆盖时为已采集量} skuOutboundOrderCount: {type: string, nullable: true} physicalQuantity: {type: number, nullable: true} availableQuantity: {type: number, nullable: true} @@ -490,7 +493,7 @@ components: properties: windowStartDate: {type: string, format: date} windowEndDateExclusive: {type: string, format: date} - calculationWindowDays: {type: integer, enum: [30]} + calculationWindowDays: {type: integer, minimum: 1, description: 热销为所选日期天数;库存榜固定30} eligibleCount: {type: string, nullable: true} unassessableCount: {type: string, nullable: true} items: {type: array, maxItems: 10, items: {$ref: '#/components/schemas/DashboardRankingItem'}} 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 1740cac7a..20aba784c 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 @@ -35,6 +35,14 @@ class AnalyticsQueryServiceTest { when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35)))); when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of()); } + @Test void customHotRangeMatchesSelectedDatesIncludingTodayAndLongHistory(){ + var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(44),D,null,null,1,20,null,null,null,null,null); + var result=service.hotSkus(q); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture()); + assertEquals(D.minusDays(44),cap.getValue().from());assertEquals(D.plusDays(1),cap.getValue().until()); + 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 summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){ when(mapper.count(any())).thenReturn(240L); var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java index af3c646e7..a6d46fcb5 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 @@ -34,7 +34,7 @@ class AnalyticsReadMapperTest { } var b=bound(c,"page",Map.of("p",request("HOT"))); var names=b.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(); - for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.anchor","p.windowFrom","p.size","p.offset"))assertTrue(names.contains(property),property); + for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.anchor","p.from","p.until","p.size","p.offset"))assertTrue(names.contains(property),property); var summaryNames=bound(c,"page",Map.of("p",request("SUMMARY"))).getParameterMappings().stream() .map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(); for(String property:List.of("p.from","p.until"))assertTrue(summaryNames.contains(property),property); @@ -83,7 +83,8 @@ class AnalyticsReadMapperTest { var p=Map.of("p",request(kind)); String sql=bound(c,"page",p).getSql(); assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?")); assertTrue(sql.contains("AND st.product_quality=0")); - assertTrue(sql.contains("business_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_dateb.skuCount()==null)); assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(30),result.context().fixedWindowStartDate()); - verify(mapper).daily(any(),any(),any());verify(mapper).report(any(),any());verifyNoMoreInteractions(mapper); + verify(mapper).daily(any(),any(),any());verify(mapper,times(2)).report(any(),any());verifyNoMoreInteractions(mapper); var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext")); assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly()); } 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 d8ee3aad3..f152bf4c0 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 @@ -39,6 +39,21 @@ class DashboardSqlSemanticTest { assertTrue(bound.getSql().endsWith("ORDER BY business_date")); } } + @Test void selectedRangeHotUsesObservedSalesIncludingTodayAndMatchesTop100DespiteIncompleteCoverage()throws Exception{ + fixture("RECENT","50","50","3");fixture("OLD","50","50",null); + daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS"); + var b=request(false,true); + var p=new AnalyticsSqlRequest(b.account(),b.warehouse(),null,null,b.salesRun(),b.inventoryRun(),D.minusDays(2),D.plusDays(1),D,D.minusDays(2), + b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD"); + var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options())); + assertEquals(1,rows.size());assertEquals("RECENT",((Map)rows.get(0)).get("SKU_CODE")); + assertEquals(0,new BigDecimal("10").compareTo((BigDecimal)((Map)rows.get(0)).get("MAIN_VALUE"))); + var top=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),D,p.from(), + p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT"); + var topRows=query(AnalyticsReadMapper.class,"page",Map.of("p",top)); + assertEquals(1,topRows.size());var item=(AnalyticsReadRows.Aggregate)topRows.get(0); + assertEquals("RECENT",item.skuCode());assertEquals(0,new BigDecimal("10").compareTo(item.windowQuantity()));assertNull(item.sales30Quantity()); + } @Test void combinedReportKeepsStatisticsWhenAllThreeRankingsAreEmpty()throws Exception{ var empty=report();assertEquals(1,empty.size());assertEquals(0,count(empty.get(0),"total_skus")); assertNull(empty.get(0).get("rank_code"));assertNull(empty.get(0).get("physical_quantity_known"));