fix(self-operated): rank hot SKUs for selected dates
This commit is contained in:
+5
-4
@@ -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),"
|
||||
|
||||
+2
@@ -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天");
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+8
-8
@@ -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<TIMESTAMPADD(DAY,1,#{p.anchor}) GROUP BY customer_code,sku_code),"
|
||||
+"AND business_date>=LEAST(#{p.from},TIMESTAMPADD(DAY,-30,#{p.anchor})) AND business_date<GREATEST(#{p.until},TIMESTAMPADD(DAY,1,#{p.anchor})) GROUP BY customer_code,sku_code),"
|
||||
+" result AS (SELECT c.customer_code,c.sku_code,CASE WHEN "+windowValid+" THEN 1 ELSE 0 END unit_valid,"
|
||||
+"CASE WHEN "+windowValid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_quantity,0) END window_quantity,"
|
||||
+"CASE WHEN "+windowValid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_orders,0) END window_sku_order_count,"
|
||||
+"CASE WHEN "+windowValid+" AND #{p.salesKnown}=1 THEN CASE WHEN #{p.completeWindow}=1 THEN COALESCE(m.window_quantity,0) ELSE m.window_quantity END END window_quantity,"
|
||||
+"CASE WHEN "+windowValid+" AND #{p.salesKnown}=1 THEN CASE WHEN #{p.completeWindow}=1 THEN COALESCE(m.window_orders,0) ELSE m.window_orders END END window_sku_order_count,"
|
||||
+"CASE WHEN "+thirtyValid+" AND #{p.complete30}=1 THEN COALESCE(m.sales30_quantity,0) END sales30_quantity,"
|
||||
+"CASE WHEN "+todayValid+" THEN 1 ELSE 0 END today_unit_valid,"
|
||||
+"CASE WHEN "+todayValid+" AND #{p.salesKnown}=1 THEN m.today_quantity END today_quantity "
|
||||
|
||||
+22
-5
@@ -30,6 +30,13 @@ public class DashboardQueryService {
|
||||
var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days);
|
||||
var trendSummary=SalesBucketPolicy.aggregate(trendPoints);
|
||||
var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days);var fixed=SalesBucketPolicy.aggregate(fixedPoints);
|
||||
Map<String,Object> hotStats=stats;List<DashboardReadMapper.Ranked> 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<String,Object> 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<String,Object> s,List<DashboardReadMapper.Ranked> 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<DashboardReadMapper.Ranked> 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.<DashboardResponses.Item>of();
|
||||
divide("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),windowDays),"HOT".equals(kind)?null:r.coverageDays())).toList():List.<DashboardResponses.Item>of();
|
||||
long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE";
|
||||
List<String> 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<String,Object> normalize(Map<String,Object> source){Map<String,Object> result=new HashMap<>();if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));return result;}
|
||||
static DashboardReadMapper.Ranked ranked(Map<String,Object> row){
|
||||
@@ -95,5 +111,6 @@ public class DashboardQueryService {
|
||||
}
|
||||
private static BigDecimal decimal(Map<String,Object> 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<String,Object> 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);}
|
||||
}
|
||||
|
||||
+4
@@ -8,6 +8,10 @@ public final class DashboardReadSql {
|
||||
private DashboardReadSql(){}
|
||||
public static String report(Map<String,Object> 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
|
||||
|
||||
+1
-1
@@ -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<Bucket> buckets,String dataStatus,List<String> reasonCodes,Map<String,Long> 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,
|
||||
|
||||
Reference in New Issue
Block a user