feat(self-operated): support selected-day analytics and global quantity sorting

This commit is contained in:
2026-10-14 12:44:15 +08:00
parent 287edcf32b
commit bc9c78122b
31 changed files with 329 additions and 144 deletions
@@ -30,8 +30,9 @@ public class PlatformAnalyticsController {
@GetMapping("/sku-sales-summary")
@Operation(summary="五周期或历史自定义范围SKU累计清单")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> 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的完整日趋势轴")
@@ -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("货主编码前缀不能为空或超长");
@@ -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<String,Object> 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.
@@ -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天");
@@ -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<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> 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<String> 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<String,String> 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<String> 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));
@@ -18,6 +18,8 @@ public interface AnalyticsReadMapper {
AnalyticsReadRows.Run publishedRun(@Param("id") long id);
@SelectProvider(type=AnalyticsReadSql.class,method="page")
List<AnalyticsReadRows.Aggregate> page(@Param("p") AnalyticsSqlRequest request);
@SelectProvider(type=AnalyticsReadSql.class,method="page")
List<AnalyticsReadRows.Aggregate> 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){}
@@ -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<String>();
for(int i=0;i<p.coverage().size();i++)intervals.add("SELECT CAST(#{p.coverage["+i+"].from} AS DATETIME) lo,CAST(#{p.coverage["+i+"].until} AS DATETIME) hi");
var dates=new java.util.ArrayList<String>();
@@ -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)))<LEAST(v.hi,TIMESTAMPADD(HOUR,-8,CAST(#{p.until} AS DATETIME))))) THEN COALESCE(s.observed_sum,0) END 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 observed_quantity IS NULL,observed_quantity DESC,sku_code,customer_code LIMIT #{p.size} OFFSET #{p.offset}),"
+" paged_keys AS (SELECT * FROM rankable ORDER BY "+salesSort.sql("observed_quantity")+" LIMIT #{p.size} OFFSET #{p.offset}),"
+" day_counts AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_order_count) sku_order_count,"
+"SUM(CASE WHEN #{p.formalSales}=1 AND "+whole+" THEN 1 ELSE 0 END) complete_count,"
+"SUM(CASE WHEN #{p.salesKnown}=1 AND (d.id IS NOT NULL OR "+observed+") THEN 1 ELSE 0 END) observed_count"
@@ -187,12 +187,12 @@ public final class AnalyticsReadSql {
private static String rankingCte(){
String valid="c.profile_unit_valid=1 AND (m.min_unit IS NULL OR (m.min_unit=c.quantity_unit AND m.max_unit=c.quantity_unit))";
return candidates(false)+" 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.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<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<TIMESTAMPADD(DAY,1,#{p.anchor}) GROUP BY customer_code,sku_code),"
+" result AS (SELECT c.customer_code,c.sku_code,c.product_name,c.quantity_unit,c.evidence_at_utc,CASE WHEN "+valid+" THEN 1 ELSE 0 END unit_valid,"
+"CAST(NULL AS DECIMAL(20,6)) observed_quantity,CAST(NULL AS SIGNED) sku_order_count,0 complete_day_count,0 partial_day_count,0 unknown_day_count,"
+"CASE WHEN "+valid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_quantity,0) END window_quantity,"
@@ -211,7 +211,7 @@ public final class AnalyticsReadSql {
private static String filter(String kind){
if("HOT".equals(kind))return " WHERE window_quantity>0 ";
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<String,Object> 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
@@ -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<String> reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity,
BigDecimal windowOutboundQuantity,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
BigDecimal windowOutboundQuantity,BigDecimal observedWindowOutboundQuantity,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
List<String> attentionReasons,Map<String,String> quantityStatuses){}
public record OwnerOption(String customerCode,String customerName){}
public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List<String> reasonCodes){}
@@ -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();
@@ -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)")
@@ -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<String> 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));
@@ -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<Sample> 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<LocalDate,Long>();
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<Sample> 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<Sample> 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("库存周转上下文无效或已过期,请整体刷新");}
}
@@ -21,9 +21,9 @@ public final class InventoryTurnoverPolicy {
static String text(Map<String,Object> 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<Window> windows(Map<String,Object> row,LocalDate until) {
static List<Window> windows(Map<String,Object> row,LocalDate until, java.util.List<Integer> sizes) {
List<Window> 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<String,Object> 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<LocalDate> 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<String> 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");
@@ -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);
}
@@ -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<Integer> 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("可售天数阈值须为正整数");
@@ -18,7 +18,7 @@ public final class InventoryTurnoverResponses {
BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity,
BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays,
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
List<String> attentionReasons,Risk noOutbound30d,Risk slowCoverage,int unassessableSkuCount) {}
List<String> 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,
@@ -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<Day> 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_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 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<String> 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<String> 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<String> summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown"));
@@ -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");
@@ -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";
}
}