优化
This commit is contained in:
+1
-1
@@ -52,7 +52,7 @@ public class PlatformAnalyticsController {
|
||||
requirePlatform(user);return ApiResponse.success(service.slowSkus(query));
|
||||
}
|
||||
@GetMapping("/inventory-coverage")
|
||||
@Operation(summary="正品库存、10或30完整日日均和预计可售天数")
|
||||
@Operation(summary="正品库存与所选窗口日均和预计可售天数,支持总览原图区间下钻")
|
||||
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.InventoryMetric>> inventoryCoverage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
|
||||
requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options));
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ public interface SelfopCoordinationMapper {
|
||||
SelfopSyncRun lockRun(long id);
|
||||
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
|
||||
List<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose);
|
||||
@Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR))) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}")
|
||||
// Started inventory work may resume across midnight; unstarted historical slots remain excluded.
|
||||
// Publication independently requires the planned day and observation day to match for daily samples.
|
||||
@Select("SELECT t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)) OR (r.attempt_count>0 AND r.planned_at_utc<=#{now})) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}")
|
||||
List<SelfopSyncTask> dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account);
|
||||
@Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))")
|
||||
long barriers(@Param("account") String account, @Param("warehouse") String warehouse);
|
||||
|
||||
+4
-2
@@ -13,12 +13,14 @@ public record AnalyticsInventoryOptions(String keyword,String attention,String s
|
||||
public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
|
||||
public void validate(String endpoint,AnalyticsQuery q){
|
||||
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(!"INVENTORY".equals(endpoint)&&(salesWindowDays!=null||salesWindowEndDate!=null))throw new BizException("销量窗口仅用于库存明细");
|
||||
if(!"INVENTORY".equals(endpoint)&&(coverageBucket!=null||coverageSelection!=null||drilldownFrom!=null))throw new BizException("分布下钻条件仅用于库存明细");
|
||||
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数");
|
||||
if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效");
|
||||
if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))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("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("货主编码前缀不能为空或超长");
|
||||
|
||||
+10
-10
@@ -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())||"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}";
|
||||
String days=("INVENTORY".equals(p.kind())||"DASHBOARD".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())||"DASHBOARD".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,7 +36,7 @@ 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&&!"HOT_DASHBOARD".equals(p.kind())&&!p.complete30())
|
||||
+salesCte(p,stockFirst,dashboard&&!"HOT_DASHBOARD".equals(p.kind())&&!p.completeWindow())
|
||||
+" 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,"
|
||||
@@ -67,26 +67,26 @@ public final class AnalyticsInventorySql {
|
||||
+"ORDER BY "+amount+" IS NULL,"+amount+" DESC,c.sku_code ASC,c.customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}),";}
|
||||
private static boolean stockFirst(AnalyticsInventoryOptions o){return "ALL".equals(o.attentionValue())&&"AVAILABLE_DESC".equals(o.sortValue())
|
||||
&&o.coverageBucket()==null&&o.coverageSelection()==null;}
|
||||
private static String filter(AnalyticsInventoryOptions o){
|
||||
String distribution=CoverageDistributionSql.filter(o);
|
||||
private static String filter(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
String distribution=CoverageDistributionSql.filter(p,o);
|
||||
if(!distribution.isEmpty())return " WHERE "+distribution;
|
||||
return switch(o.attentionValue()){
|
||||
case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1";
|
||||
case "NO_OUTBOUND_IN_WINDOW" -> " WHERE no_outbound=1";
|
||||
case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1";
|
||||
case "FROZEN_STOCK" -> " WHERE frozen_quantity>0";
|
||||
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_30D_ASC" -> "outbound_quantity30d";default -> "available_quantity";};
|
||||
return field+" IS NULL,"+field+("OUTBOUND_30D_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";}
|
||||
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";}
|
||||
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.
|
||||
boolean stockFirst=stockFirst(o);
|
||||
// ALL/available ordering depends only on stock; aggregate sales after selecting these keys.
|
||||
String paged=cte(p,stockFirst)+", paged AS (SELECT * FROM result"
|
||||
+(stockFirst?"":filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}")+") "
|
||||
+(stockFirst?"":filter(p,o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}")+") "
|
||||
+(stockFirst?"":", last_sales AS (SELECT d.customer_code,d.sku_code,MAX(d.business_date) last_outbound FROM ll_selfop_sku_daily_sales d "
|
||||
+"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} "
|
||||
@@ -105,7 +105,7 @@ public final class AnalyticsInventorySql {
|
||||
public static String inventoryCount(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
|
||||
if(o.coverageBucket()==null&&o.coverageSelection()==null&&java.util.Set.of("FROZEN_STOCK","DEFECTIVE_STOCK").contains(o.attentionValue()))
|
||||
return typed(stockAttentionCount(p,o));
|
||||
return typed("ALL".equals(o.attentionValue())&&o.coverageBucket()==null&&o.coverageSelection()==null?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));}
|
||||
return typed("ALL".equals(o.attentionValue())&&o.coverageBucket()==null&&o.coverageSelection()==null?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(p,o));}
|
||||
/** These predicates depend only on stock. Counting them must not aggregate sales
|
||||
* or inspect the other profile version, even when those sales are unavailable. */
|
||||
private static String stockAttentionCount(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
|
||||
+3
-2
@@ -179,7 +179,8 @@ public class AnalyticsQueryService {
|
||||
r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of());
|
||||
}
|
||||
Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot,AnalyticsInventoryOptions inventoryOptions){
|
||||
q.validate(chart,exact,hot); Instant now=clock.instant();
|
||||
boolean dashboardWindow=inventoryOptions!=null&&"DASHBOARD".equals(inventoryOptions.drilldownFrom());
|
||||
q.validate(chart||dashboardWindow,exact,hot); Instant now=clock.instant();
|
||||
List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode());
|
||||
Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY");
|
||||
if(salesScope==null&&stockScope==null)throw new BizException("来源账号或仓库未配置");
|
||||
@@ -204,7 +205,7 @@ public class AnalyticsQueryService {
|
||||
LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate();
|
||||
SalesRangePolicy.Range range;
|
||||
try{
|
||||
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(),
|
||||
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(),
|
||||
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());
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import java.util.List;
|
||||
/** V1's partition and strict read lifetime are a local report contract, never an authorization. */
|
||||
public final class CoverageDistributionPolicy {
|
||||
public static final String RULE_VERSION="COVERAGE_DISTRIBUTION_V1";
|
||||
public static final List<String> BUCKETS=List.of("DAYS_0_30","DAYS_30_60","DAYS_60_90","DAYS_GT_90","NO_OUTBOUND_30D","NO_AVAILABLE","UNKNOWN");
|
||||
public static final List<String> BUCKETS=List.of("DAYS_0_30","DAYS_30_60","DAYS_60_90","DAYS_GT_90","NO_OUTBOUND_IN_WINDOW","NO_AVAILABLE","UNKNOWN");
|
||||
private CoverageDistributionPolicy(){}
|
||||
public static void validate(AnalyticsInventoryOptions o){
|
||||
if(o.coverageBucket()!=null&&(!BUCKETS.contains(o.coverageBucket())||!RULE_VERSION.equals(o.bucketRuleVersion())))throw new BizException("库存分布桶或规则版本无效");
|
||||
|
||||
+7
-7
@@ -5,17 +5,17 @@ public final class CoverageDistributionSql {
|
||||
private CoverageDistributionSql(){}
|
||||
public static String candidate(){return "(physical_quantity IS NULL OR physical_quantity>0)";}
|
||||
public static String finitePositive(){return "available_quantity>0 AND outbound_quantity30d>0";}
|
||||
public static String bucket(){return "CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL THEN 'UNKNOWN' "
|
||||
public static String bucket(AnalyticsSqlRequest p){long days=java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until());return "CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL THEN 'UNKNOWN' "
|
||||
+"WHEN physical_quantity=0 THEN NULL WHEN available_quantity=0 THEN 'NO_AVAILABLE' "
|
||||
+"WHEN outbound_quantity30d IS NULL THEN 'UNKNOWN' WHEN outbound_quantity30d=0 THEN 'NO_OUTBOUND_30D' "
|
||||
+"WHEN available_quantity*30<=outbound_quantity30d*30 THEN 'DAYS_0_30' "
|
||||
+"WHEN available_quantity*30<=outbound_quantity30d*60 THEN 'DAYS_30_60' "
|
||||
+"WHEN available_quantity*30<=outbound_quantity30d*90 THEN 'DAYS_60_90' ELSE 'DAYS_GT_90' END";}
|
||||
+"WHEN outbound_quantity30d IS NULL THEN 'UNKNOWN' WHEN outbound_quantity30d=0 THEN 'NO_OUTBOUND_IN_WINDOW' "
|
||||
+"WHEN available_quantity*"+days+"<=outbound_quantity30d*30 THEN 'DAYS_0_30' "
|
||||
+"WHEN available_quantity*"+days+"<=outbound_quantity30d*60 THEN 'DAYS_30_60' "
|
||||
+"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";}
|
||||
public static String filter(AnalyticsInventoryOptions o){
|
||||
if(o.coverageBucket()!=null)return "#{p.inventoryRun}>0 AND "+candidate()+" AND ("+bucket()+")=#{o.coverageBucket}";
|
||||
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();
|
||||
return "";
|
||||
}
|
||||
|
||||
+11
-12
@@ -20,19 +20,19 @@ public class DashboardQueryService {
|
||||
var snapshot=contextService.context(q,true,false,false,null);var c=snapshot.context();
|
||||
if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady()))
|
||||
throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询");
|
||||
var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate();
|
||||
var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom,
|
||||
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage());
|
||||
var base=contextService.request(snapshot,"DASHBOARD");LocalDate windowFrom=c.rangeStartDate(),windowUntil=c.rangeEndDateExclusive();
|
||||
int windowDays=(int)java.time.temporal.ChronoUnit.DAYS.between(windowFrom,windowUntil);
|
||||
var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),windowFrom,windowUntil,base.anchor(),windowFrom,
|
||||
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage());
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);
|
||||
var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive());
|
||||
var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList();
|
||||
Map<String,Object> stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList();
|
||||
var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days,SalesQueryCoverage.intervals(snapshot.sales()));
|
||||
var trendSummary=SalesBucketPolicy.aggregate(trendPoints);
|
||||
var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days,SalesQueryCoverage.intervals(snapshot.sales()));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,
|
||||
if(!base.completeWindow()){
|
||||
var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),windowFrom,
|
||||
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD",base.coverage());
|
||||
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();
|
||||
@@ -41,19 +41,19 @@ public class DashboardQueryService {
|
||||
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();
|
||||
var interval=new DashboardResponses.Metric(trendSummary.outboundQuantity(),trendSummary.observedQuantity(),"PCS",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),trendSummary.outboundQuantity(),trendSummary.observedQuantity());
|
||||
// A partial observed quantity is never divided by 30 and presented as an estimated daily average.
|
||||
var average=new DashboardResponses.Metric(divide30(fixed.outboundQuantity()),divide30(fixed.outboundQuantity()),"PCS_PER_DAY",fixed.dataStatus(),null,fixed.reasonCodes(),null,null);
|
||||
// Partial sales never become a formal daily average for the selected window.
|
||||
var average=new DashboardResponses.Metric(divide(trendSummary.outboundQuantity(),windowDays),divide(trendSummary.outboundQuantity(),windowDays),"PCS_PER_DAY",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),null,null);
|
||||
boolean inventoryAvailable=base.inventoryRun()>0;
|
||||
var kpis=new DashboardResponses.Kpis(interval,average,stockMetric(stats,"physical_quantity_known","physical_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c),
|
||||
stockMetric(stats,"available_quantity_known","available_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c),
|
||||
stockMetric(stats,"physical_skus","physical_quantity_unknown","SKU",inventoryAvailable,base.stockFresh(),c),
|
||||
stockMetric(stats,"no_outbound_eligible","no_outbound_unknown","SKU",inventoryAvailable,base.stockFresh(),c));
|
||||
Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil);
|
||||
return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,fixedFrom,fixedUntil,30,freshUntil,drilldownUntil),kpis,
|
||||
return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,freshUntil,drilldownUntil),kpis,
|
||||
new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,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));
|
||||
ranking("LONG",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c),
|
||||
ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,windowFrom,windowUntil,c));
|
||||
}
|
||||
private static DashboardResponses.Ranking hotRanking(DashboardResponses.Ranking ranking,String windowStatus){
|
||||
if("COMPLETE".equals(windowStatus))return ranking;
|
||||
@@ -111,6 +111,5 @@ 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 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);}
|
||||
}
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ public final class DashboardReadSql {
|
||||
+", 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
|
||||
// Only dashboard outputs are considered here. Without completeWindow 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
|
||||
// candidate identities, observation evidence and unknown reasons remain unchanged.
|
||||
@@ -21,7 +21,7 @@ public final class DashboardReadSql {
|
||||
// share one calculation rather than expanding profile/stock/sales joins for each ranking.
|
||||
sql.append(", dashboard_result AS (SELECT DISTINCT * FROM result)");
|
||||
sql.append(", classified AS (SELECT r.*,CASE WHEN ").append(CoverageDistributionSql.candidate()).append(" THEN ")
|
||||
.append(CoverageDistributionSql.bucket()).append(" END bucket_code,").append(CoverageDistributionSql.unknownReason()).append(" unknown_reason FROM dashboard_result r), statistics AS (SELECT COUNT(*) total_skus");
|
||||
.append(CoverageDistributionSql.bucket(p)).append(" END bucket_code,").append(CoverageDistributionSql.unknownReason()).append(" unknown_reason FROM dashboard_result r), statistics AS (SELECT COUNT(*) total_skus");
|
||||
for(String field:List.of("physical_quantity","available_quantity"))sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
|
||||
sql.append(",COUNT(CASE WHEN physical_quantity>0 THEN 1 END) physical_skus,COUNT(CASE WHEN physical_quantity=0 THEN 1 END) excluded_zero_skus")
|
||||
.append(",COUNT(CASE WHEN bucket_code IS NOT NULL THEN 1 END) candidate_skus")
|
||||
|
||||
+3
-4
@@ -9,12 +9,11 @@ import java.util.Map;
|
||||
|
||||
public final class DashboardResponses {
|
||||
private DashboardResponses(){}
|
||||
public record Context(@JsonUnwrapped AnalyticsResponses.Context versionContext,LocalDate fixedWindowStartDate,
|
||||
LocalDate fixedWindowEndDateExclusive,int fixedWindowDays,Instant inventoryFreshUntil,Instant inventoryDrilldownExpiresAt){}
|
||||
public record Context(@JsonUnwrapped AnalyticsResponses.Context versionContext,Instant inventoryFreshUntil,Instant inventoryDrilldownExpiresAt){}
|
||||
public record Metric(BigDecimal value,BigDecimal knownSubtotal,String unit,String dataStatus,Long unknownSkuCount,
|
||||
List<String> reasonCodes,BigDecimal outboundQuantity,BigDecimal observedQuantity){}
|
||||
public record Kpis(Metric intervalOutboundQuantity,Metric averageDailyOutboundQuantity30d,Metric physicalQuantity,
|
||||
Metric availableQuantity,Metric physicalSkuCount,Metric noOutboundSkuCount30d){}
|
||||
public record Kpis(Metric intervalOutboundQuantity,Metric averageDailyOutboundQuantity,Metric physicalQuantity,
|
||||
Metric availableQuantity,Metric physicalSkuCount,Metric noOutboundSkuCount){}
|
||||
public record SalesTrend(AnalyticsResponses.SalesSummary summary,List<AnalyticsResponses.Point> points){}
|
||||
public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){}
|
||||
public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount,
|
||||
|
||||
+1
-1
@@ -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(input)),context.endDate().plusDays(1)));
|
||||
charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1)));
|
||||
}
|
||||
return new Response(context,summary,charts,List.copyOf(rows),total);
|
||||
}
|
||||
|
||||
+5
-2
@@ -38,8 +38,11 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod
|
||||
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0 || attention().startsWith("COVERAGE_")&&coverageDaysThreshold==null)
|
||||
throw new BizException("可售天数阈值须为正整数");
|
||||
if(turnoverStatus!=null&&!"INSUFFICIENT".equals(turnoverStatus)) throw new BizException("周转状态无效");
|
||||
if(!Set.of("PHYSICAL_DESC","AVAILABLE_DESC","OUTBOUND_7_DESC","OUTBOUND_15_DESC","OUTBOUND_30_DESC",
|
||||
"COVERAGE_ASC","COVERAGE_DESC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort())
|
||||
if(!Set.of("PHYSICAL_ASC","PHYSICAL_DESC","AVAILABLE_ASC","AVAILABLE_DESC",
|
||||
"OUTBOUND_7_ASC","OUTBOUND_7_DESC","OUTBOUND_15_ASC","OUTBOUND_15_DESC","OUTBOUND_30_ASC","OUTBOUND_30_DESC",
|
||||
"AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC","COVERAGE_ASC","COVERAGE_DESC",
|
||||
"FROZEN_ASC","FROZEN_DESC","DEFECTIVE_ASC","DEFECTIVE_DESC","AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC",
|
||||
"TURNOVER_ASC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort())
|
||||
|| "OWNER".equals(group())&&sort().startsWith("COVERAGE_")
|
||||
|| "SKU".equals(group())&&"NO_OUTBOUND_PHYSICAL_DESC".equals(sort())) throw new BizException("排序不适用于当前分组");
|
||||
if(page()<1||pageSize()<1||pageSize()>100) throw new BizException("分页范围为current≥1,size为1至100");
|
||||
|
||||
+24
-6
@@ -19,8 +19,16 @@ public final class InventoryTurnoverSql {
|
||||
public LocalDateTime fromUtc(){return from().atStartOfDay().minusHours(8);}
|
||||
}
|
||||
static Input input(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen) {
|
||||
return input(q,frozen,false);
|
||||
}
|
||||
static Input chartInput(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen) {
|
||||
// Historical charts read only the frozen published sales version. Pending fact merges
|
||||
// cannot erase its coverage; current stock assessments still use the stricter input.
|
||||
return input(q,frozen,true);
|
||||
}
|
||||
private static Input input(InventoryTurnoverRequest q,InventoryTurnoverContext.Frozen frozen,boolean publishedHistory) {
|
||||
var coverage=SalesQueryCoverage.intervals(frozen.snapshot().sales());
|
||||
boolean trusted=frozen.snapshot().sourceKnown()&&!frozen.response().analyticsContext().rebuildRequired();
|
||||
boolean trusted=frozen.snapshot().sourceKnown()&&(publishedHistory||!frozen.response().analyticsContext().rebuildRequired());
|
||||
var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8),
|
||||
!trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList();
|
||||
return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(),
|
||||
@@ -202,12 +210,22 @@ public final class InventoryTurnoverSql {
|
||||
}
|
||||
private static String order(Input p,boolean owner) {
|
||||
String field=switch(p.query.sort()) {
|
||||
case "AVAILABLE_DESC" -> "available";case "OUTBOUND_7_DESC" -> "w7_outbound";
|
||||
case "OUTBOUND_15_DESC" -> "w15_outbound";case "OUTBOUND_30_DESC" -> "w30_outbound";
|
||||
case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days";case "TURNOVER_DESC" -> "turnover";
|
||||
case "NO_OUTBOUND_PHYSICAL_DESC" -> "no_outbound_physical";default -> "physical";
|
||||
case "PHYSICAL_ASC","PHYSICAL_DESC" -> "physical";
|
||||
case "AVAILABLE_ASC","AVAILABLE_DESC" -> "available";
|
||||
case "OUTBOUND_7_ASC","OUTBOUND_7_DESC" -> "w7_outbound";
|
||||
case "OUTBOUND_15_ASC","OUTBOUND_15_DESC" -> "w15_outbound";
|
||||
case "OUTBOUND_30_ASC","OUTBOUND_30_DESC" -> "w30_outbound";
|
||||
// The denominator is constant for the whole query; use the unrounded numerator.
|
||||
case "AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC" -> "w"+p.query.days()+"_outbound";
|
||||
case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days";
|
||||
case "FROZEN_ASC","FROZEN_DESC" -> "frozen";
|
||||
case "DEFECTIVE_ASC","DEFECTIVE_DESC" -> "defective";
|
||||
case "AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC" -> "average_physical";
|
||||
case "TURNOVER_ASC","TURNOVER_DESC" -> "turnover";
|
||||
case "NO_OUTBOUND_PHYSICAL_DESC" -> "no_outbound_physical";
|
||||
default -> throw new IllegalArgumentException("Unsupported turnover sort");
|
||||
};
|
||||
return " ORDER BY "+field+" IS NULL,"+field+("COVERAGE_ASC".equals(p.query.sort())?" ASC":" DESC")+(owner?",customer_code":",sku_code,customer_code");
|
||||
return " ORDER BY "+field+" IS NULL,"+field+(p.query.sort().endsWith("_ASC")?" ASC":" DESC")+(owner?",customer_code":",sku_code,customer_code");
|
||||
}
|
||||
public static String page(Map<String,Object> args) {
|
||||
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group());
|
||||
|
||||
+2
-6
@@ -7,7 +7,7 @@ import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Inventory-only complete natural-day window; dashboard and ranking policies remain fixed. */
|
||||
/** Inventory-only complete natural-day window; dashboard drilldowns resolve their explicit selected dates separately. */
|
||||
final class InventoryWindowPolicy {
|
||||
private InventoryWindowPolicy() {}
|
||||
static SalesRangePolicy.Range range(AnalyticsInventoryOptions o,LocalDate anchor) {
|
||||
@@ -15,17 +15,13 @@ final class InventoryWindowPolicy {
|
||||
if(days!=10&&days!=30)throw new BizException("库存销量窗口仅支持10或30天");
|
||||
LocalDate end=o.salesWindowEndDate()==null?anchor.minusDays(1):o.salesWindowEndDate();
|
||||
if(!end.isBefore(anchor))throw new BizException("库存销量截止日必须早于查询当日");
|
||||
boolean defaultThirty=days==30&&end.equals(anchor.minusDays(1));
|
||||
if(!defaultThirty&&(o.coverageBucket()!=null||o.coverageSelection()!=null||o.drilldownFrom()!=null
|
||||
||"NO_OUTBOUND_30D".equals(o.attentionValue())||"OUTBOUND_30D_ASC".equals(o.sortValue())))
|
||||
throw new BizException("30日关注、排序及总览下钻仅适用于截至昨日的30日窗口");
|
||||
LocalDate until=end.plusDays(1),from=until.minusDays(days);var zone=ZoneId.of("Asia/Shanghai");
|
||||
return new SalesRangePolicy.Range(anchor,from,until,days,from.atStartOfDay(zone).toInstant(),until.atStartOfDay(zone).toInstant());
|
||||
}
|
||||
record Result(BigDecimal average,BigDecimal days,String status) {}
|
||||
static Result calculate(String quality,BigDecimal available,BigDecimal sales,boolean trusted,
|
||||
boolean complete,boolean observed,boolean unit,int days) {
|
||||
if(days!=10&&days!=30)throw new IllegalArgumentException("Inventory window must be 10 or 30 days");
|
||||
if(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);
|
||||
|
||||
Reference in New Issue
Block a user