feat(selfop): add warehouse dashboard and exact inventory drilldowns
This commit is contained in:
+8
-1
@@ -19,7 +19,14 @@ public class PlatformAnalyticsController {
|
||||
public static final String READ_PERMISSION="platform:self-operated-analytics:list";
|
||||
private final AnalyticsQueryService service;
|
||||
private final CollectionQueryService collection;
|
||||
public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection){this.service=service;this.collection=collection;}
|
||||
private final DashboardQueryService dashboard;
|
||||
public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection,DashboardQueryService dashboard){this.service=service;this.collection=collection;this.dashboard=dashboard;}
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary="同仓固定版本的经营总览图表与精确下钻上下文")
|
||||
public ApiResponse<DashboardResponses.Dashboard> dashboard(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,
|
||||
@RequestParam java.util.Map<String,String> parameters){
|
||||
requirePlatform(user);DashboardRequest.validateParameterNames(parameters.keySet());return ApiResponse.success(dashboard.dashboard(query));
|
||||
}
|
||||
@GetMapping("/sku-sales-summary")
|
||||
@Operation(summary="五周期或历史自定义范围SKU累计清单")
|
||||
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> salesSummary(
|
||||
|
||||
+4
-1
@@ -4,11 +4,14 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import java.util.Set;
|
||||
|
||||
/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */
|
||||
public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold) {
|
||||
public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold,
|
||||
String coverageBucket,String bucketRuleVersion,String coverageSelection,String drilldownFrom) {
|
||||
public String attentionValue(){return attention==null?"ALL":attention;}
|
||||
public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;}
|
||||
public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
|
||||
public void validate(String endpoint,AnalyticsQuery q){
|
||||
CoverageDistributionPolicy.validate(this);
|
||||
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("库存排序无效");
|
||||
|
||||
+21
-6
@@ -13,7 +13,9 @@ public final class AnalyticsInventorySql {
|
||||
+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);}
|
||||
private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
|
||||
private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
|
||||
private static String cte(AnalyticsSqlRequest p){
|
||||
private static String defectiveQuantity(){return "CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 "
|
||||
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END";}
|
||||
static String cte(AnalyticsSqlRequest p){
|
||||
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,"
|
||||
@@ -24,8 +26,7 @@ public final class AnalyticsInventorySql {
|
||||
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code),"
|
||||
+" stock AS (SELECT customer_code,sku_code,"+quantity("physical_quantity")+" physical_quantity,"+quantity("frozen_quantity")+" frozen_quantity,"
|
||||
+quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity,"
|
||||
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 "
|
||||
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective_physical_quantity,MAX(observed_at_utc) observed_at_utc "
|
||||
+defectiveQuantity()+" defective_physical_quantity,MAX(observed_at_utc) observed_at_utc "
|
||||
+"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code),"
|
||||
+" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count,"
|
||||
+"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales "
|
||||
@@ -45,7 +46,10 @@ public final class AnalyticsInventorySql {
|
||||
+"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) ";
|
||||
}
|
||||
private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;}
|
||||
private static String filter(AnalyticsInventoryOptions o){return switch(o.attentionValue()){
|
||||
private static String filter(AnalyticsInventoryOptions o){
|
||||
String distribution=CoverageDistributionSql.filter(o);
|
||||
if(!distribution.isEmpty())return " WHERE "+distribution;
|
||||
return switch(o.attentionValue()){
|
||||
case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1";
|
||||
case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1";
|
||||
case "FROZEN_STOCK" -> " WHERE frozen_quantity>0";
|
||||
@@ -68,7 +72,18 @@ public final class AnalyticsInventorySql {
|
||||
.replace("sku_code","k.sku_code").replace("customer_code","k.customer_code");
|
||||
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,l.last_outbound,k.observation_eligible,k.outbound_quantity30d,k.coverage_days,k.no_outbound,k.above_threshold,k.unassessable FROM paged k LEFT JOIN last_sales l ON l.customer_code=k.customer_code AND l.sku_code=k.sku_code ORDER BY "+outerOrder);}
|
||||
public static String inventoryCount(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
|
||||
return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(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));}
|
||||
/** 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){
|
||||
String amount="FROZEN_STOCK".equals(o.attentionValue())?quantity("frozen_quantity"):defectiveQuantity();
|
||||
return "SELECT COUNT(*) FROM (SELECT customer_code,sku_code,"+amount+" amount FROM ll_selfop_inventory_snapshot "
|
||||
+"WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code) st "
|
||||
+"JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} "
|
||||
+"AND i.customer_code=st.customer_code AND i.sku_code=st.sku_code WHERE #{p.stockFresh}=1 AND i.quantity_unit='PCS' AND st.amount>0";
|
||||
}
|
||||
public static String ownerTotals(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count");
|
||||
for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound"))
|
||||
sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
|
||||
@@ -78,5 +93,5 @@ public final class AnalyticsInventorySql {
|
||||
private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");}
|
||||
public static String owners(Map<String,Object> args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");}
|
||||
public static String ownerCount(Map<String,Object> args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");}
|
||||
private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");}
|
||||
static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");}
|
||||
}
|
||||
|
||||
+12
-6
@@ -25,7 +25,7 @@ public class AnalyticsQueryService {
|
||||
@Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7;
|
||||
@Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());}
|
||||
AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;}
|
||||
private record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted,
|
||||
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){
|
||||
@@ -68,6 +68,7 @@ public class AnalyticsQueryService {
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.Metric> slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);}
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.Metric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){
|
||||
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY");
|
||||
CoverageDistributionPolicy.requireDrilldown(q,o,s.context(),inventoryFreshUntil(s),clock.instant());
|
||||
long total=mapper.inventoryCount(p,o);
|
||||
return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of());
|
||||
}
|
||||
@@ -134,7 +135,7 @@ public class AnalyticsQueryService {
|
||||
Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind);
|
||||
List<AnalyticsReadMapper.RankingStat> statistics=mapper.rankingStats(p);
|
||||
long total=statistics.isEmpty()?0:statistics.get(0).total();
|
||||
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList();
|
||||
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind)).toList();
|
||||
Map<String,Long> excluded=new TreeMap<>();
|
||||
for(var e:statistics)if(e.reason()!=null)excluded.put(e.reason(),e.excludedCount());
|
||||
return page(s,rows,total,excluded);
|
||||
@@ -148,7 +149,7 @@ public class AnalyticsQueryService {
|
||||
return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(),
|
||||
r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown);
|
||||
}
|
||||
private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r){
|
||||
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 stockValid="VALID".equals(r.inventoryQuality());
|
||||
@@ -161,7 +162,7 @@ public class AnalyticsQueryService {
|
||||
if(!"VALID".equals(result.status()))reasons.add(result.status());
|
||||
if(r.inventoryReason()!=null)reasons.add(r.inventoryReason());
|
||||
BigDecimal today=r.todayQuantity();
|
||||
if(today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){
|
||||
if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){
|
||||
Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant();
|
||||
Instant lower=evidence.isAfter(start)?evidence:start;
|
||||
if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom();
|
||||
@@ -172,7 +173,7 @@ public class AnalyticsQueryService {
|
||||
stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(),
|
||||
r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of());
|
||||
}
|
||||
private Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){
|
||||
Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){
|
||||
q.validate(chart,exact,hot); Instant now=clock.instant();
|
||||
List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode());
|
||||
Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY");
|
||||
@@ -233,7 +234,7 @@ public class AnalyticsQueryService {
|
||||
inventory==null?null:utc(inventory.fetchFinishedAtUtc()),salesState,inventoryState,rebuild,review,List.copyOf(reasons));
|
||||
return new Snapshot(q,sales,inventory,known,"VALID".equals(salesState),fullFrom,fullUntil,range,c);
|
||||
}
|
||||
private AnalyticsSqlRequest request(Snapshot s,String kind){
|
||||
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()),
|
||||
@@ -241,6 +242,11 @@ public class AnalyticsQueryService {
|
||||
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);
|
||||
}
|
||||
Instant inventoryFreshUntil(Snapshot s){
|
||||
return s.inventory()==null||s.inventory().fetchFinishedAtUtc()==null?null:
|
||||
utc(s.inventory().fetchFinishedAtUtc()).plusSeconds(threshold(config(s.inventory())));
|
||||
}
|
||||
Instant queryClockNow(){return clock.instant();}
|
||||
private Run resolve(Scope scope,Long requested,String hash){
|
||||
if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;}
|
||||
Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null;
|
||||
|
||||
+53
-1
@@ -29,7 +29,8 @@ public final class AnalyticsReadSql {
|
||||
return sql;
|
||||
}
|
||||
private static String cte(String kind){
|
||||
if("HOT".equals(kind)||"SLOW".equals(kind))return rankingCte();
|
||||
if("HOT".equals(kind))return hotSalesCte();
|
||||
if("SLOW".equals(kind))return rankingCte();
|
||||
String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')";
|
||||
String observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')";
|
||||
String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')";
|
||||
@@ -166,11 +167,62 @@ 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.kind())+"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())+" LIMIT #{p.size} OFFSET #{p.offset}",p);
|
||||
}
|
||||
/** Rank on sales alone. Inventory enrichment and its quality checks touch only the
|
||||
* selected page; inventory missing/invalid units cannot erase a valid sales rank. */
|
||||
private static String hotSalesCte(){
|
||||
String profileValid="(s.sku_code IS NULL OR s.quantity_unit='PCS')";
|
||||
String windowValid=profileValid+" AND COALESCE(m.window_valid,1)=1";
|
||||
String thirtyValid=profileValid+" AND COALESCE(m.thirty_valid,1)=1";
|
||||
String todayValid=profileValid+" AND COALESCE(m.today_valid,1)=1";
|
||||
return "WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER
|
||||
+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+"),"
|
||||
+" metric_sales AS (SELECT customer_code,sku_code,"
|
||||
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity,"
|
||||
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders,"
|
||||
+"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity,"
|
||||
+"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity END) today_quantity,"
|
||||
+"MIN(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid,"
|
||||
+"MIN(CASE WHEN business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid,"
|
||||
+"MIN(CASE WHEN business_date=#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) today_valid "
|
||||
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER
|
||||
+"AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<TIMESTAMPADD(DAY,1,#{p.anchor}) GROUP BY customer_code,sku_code),"
|
||||
+" result AS (SELECT c.customer_code,c.sku_code,CASE WHEN "+windowValid+" THEN 1 ELSE 0 END unit_valid,"
|
||||
+"CASE WHEN "+windowValid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_quantity,0) END window_quantity,"
|
||||
+"CASE WHEN "+windowValid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_orders,0) END window_sku_order_count,"
|
||||
+"CASE WHEN "+thirtyValid+" AND #{p.complete30}=1 THEN COALESCE(m.sales30_quantity,0) END sales30_quantity,"
|
||||
+"CASE WHEN "+todayValid+" THEN 1 ELSE 0 END today_unit_valid,"
|
||||
+"CASE WHEN "+todayValid+" AND #{p.salesKnown}=1 THEN m.today_quantity END today_quantity "
|
||||
+"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} "
|
||||
+"AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code LEFT JOIN metric_sales m ON m.customer_code=c.customer_code AND m.sku_code=c.sku_code) ";
|
||||
}
|
||||
private static String hotPage(){
|
||||
String salesEvidence="CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END";
|
||||
String stockEvidence="CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END";
|
||||
String evidence="CASE WHEN ("+salesEvidence+") IS NULL THEN "+stockEvidence+" WHEN ("+stockEvidence+") IS NULL THEN "+salesEvidence
|
||||
+" ELSE LEAST("+salesEvidence+","+stockEvidence+") END";
|
||||
String todayStart="CONVERT_TZ(CAST(#{p.anchor} AS DATETIME),'+08:00','+00:00')";
|
||||
String today="CASE WHEN k.today_unit_valid=1 AND #{p.salesKnown}=1 THEN CASE WHEN k.today_quantity IS NOT NULL THEN k.today_quantity "
|
||||
+"WHEN ("+evidence+") IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST("+todayStart+",("+evidence+"),COALESCE(#{p.coveredFrom},"+todayStart+"))<#{p.coveredUntil} THEN 0 END END";
|
||||
return hotSalesCte()+", paged AS (SELECT * FROM result WHERE window_quantity>0 ORDER BY "+order("HOT")+" LIMIT #{p.size} OFFSET #{p.offset}) "
|
||||
+"SELECT k.customer_code,k.sku_code,COALESCE(i.product_name,s.product_name) product_name,'PCS' quantity_unit,"
|
||||
+evidence+" evidence_at_utc,k.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,"
|
||||
+"k.window_quantity,k.window_sku_order_count,k.sales30_quantity,"+today+" today_quantity,st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc,"
|
||||
+"CASE WHEN st.sku_code IS NULL OR i.quantity_unit IS NULL OR i.quantity_unit<>'PCS' THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' "
|
||||
+"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' "
|
||||
+"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality,"
|
||||
+"CASE WHEN i.quantity_unit IS NULL OR i.quantity_unit<>'PCS' THEN 'UNIT_UNKNOWN_OR_CONFLICT' ELSE st.quality_reason END inventory_reason "
|
||||
+"FROM paged k LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=k.customer_code AND s.sku_code=k.sku_code "
|
||||
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=k.customer_code AND i.sku_code=k.sku_code "
|
||||
+"LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} AND st.warehouse_code=#{p.warehouse} AND st.customer_code=k.customer_code AND st.sku_code=k.sku_code AND st.product_quality=0 "
|
||||
+"ORDER BY k.window_quantity DESC,k.window_sku_order_count DESC,k.sku_code ASC,k.customer_code ASC";
|
||||
}
|
||||
public static String count(java.util.Map<String,Object> parameters){
|
||||
var p=(AnalyticsSqlRequest)parameters.get("p");
|
||||
// Candidate totals do not scan daily rows for normal lists.
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.time.Instant;
|
||||
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");
|
||||
private CoverageDistributionPolicy(){}
|
||||
public static void validate(AnalyticsInventoryOptions o){
|
||||
if(o.coverageBucket()!=null&&(!BUCKETS.contains(o.coverageBucket())||!RULE_VERSION.equals(o.bucketRuleVersion())))throw new BizException("库存分布桶或规则版本无效");
|
||||
if(o.coverageBucket()==null&&o.bucketRuleVersion()!=null)throw new BizException("分布规则版本必须与分桶条件一起提供");
|
||||
if(o.coverageSelection()!=null&&!"FINITE_POSITIVE".equals(o.coverageSelection()))throw new BizException("库存有限天数筛选无效");
|
||||
if(o.coverageBucket()!=null&&(o.coverageSelection()!=null||!"ALL".equals(o.attentionValue()))||o.coverageSelection()!=null&&!"ALL".equals(o.attentionValue()))throw new BizException("分桶、有限天数与关注条件互斥");
|
||||
if(o.drilldownFrom()!=null&&!"DASHBOARD".equals(o.drilldownFrom()))throw new BizException("库存下钻来源无效");
|
||||
}
|
||||
public static Instant expiresAt(AnalyticsResponses.Context context,Instant freshUntil){
|
||||
if(context.inventoryRunId()==null||context.inventoryRunId()==0||!"VALID".equals(context.inventoryStatus())||freshUntil==null)return null;
|
||||
return freshUntil.isBefore(context.queryExpiresAt())?freshUntil:context.queryExpiresAt();
|
||||
}
|
||||
public static void requireDrilldown(AnalyticsQuery q,AnalyticsInventoryOptions o,AnalyticsResponses.Context context,Instant freshUntil,Instant now){
|
||||
if(!"DASHBOARD".equals(o.drilldownFrom()))return;
|
||||
if(!q.pinned())throw new BizException("经营总览下钻必须携带原完整版本上下文");
|
||||
Instant expires=expiresAt(context,freshUntil);
|
||||
if(expires==null||!now.isBefore(expires))throw new BizException("原图库存已过期,请刷新总览");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
/** Expressions address AnalyticsInventorySql.result. Comparisons use cross products, never display-rounded days. */
|
||||
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' "
|
||||
+"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";}
|
||||
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}";
|
||||
if(o.coverageSelection()!=null)return finitePositive();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public class DashboardQueryService {
|
||||
private final AnalyticsQueryService contextService;
|
||||
private final DashboardReadMapper mapper;
|
||||
public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper){this.contextService=contextService;this.mapper=mapper;}
|
||||
public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){
|
||||
AnalyticsQuery q=DashboardRequest.normalize(input);
|
||||
var snapshot=contextService.context(q,true,false,false);var c=snapshot.context();
|
||||
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");
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);
|
||||
var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive());
|
||||
Map<String,Object> stats=normalize(mapper.statistics(p,o));var ranked=mapper.rankings(p,o);
|
||||
var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days);
|
||||
var trendSummary=SalesBucketPolicy.aggregate(trendPoints);
|
||||
var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days);var fixed=SalesBucketPolicy.aggregate(fixedPoints);
|
||||
var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(),
|
||||
trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays());
|
||||
var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList();
|
||||
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);
|
||||
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,
|
||||
new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c),
|
||||
ranking("HOT",stats,ranked,base.salesRun()>0,fixedFrom,fixedUntil,c),
|
||||
ranking("LONG",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c),
|
||||
ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c));
|
||||
}
|
||||
private static DashboardResponses.Metric stockMetric(Map<String,Object> s,String knownField,String unknownField,String unit,boolean available,boolean fresh,AnalyticsResponses.Context c){
|
||||
if(!available)return new DashboardResponses.Metric(null,null,unit,"UNKNOWN",null,List.of("INVENTORY_UNAVAILABLE"),null,null);
|
||||
long unknown=count(s,unknownField);BigDecimal known=decimal(s,knownField);
|
||||
if(count(s,"total_skus")==0&&fresh)known=BigDecimal.ZERO;
|
||||
if(!fresh&&count(s,"total_skus")==0)known=null;
|
||||
BigDecimal value=fresh&&unknown==0?known:null;
|
||||
String status=value!=null?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";
|
||||
List<String> reasons=value!=null?List.of():!fresh?List.of("INVENTORY_"+c.inventoryStatus()):List.of("METRIC_DEPENDENCY_UNKNOWN");
|
||||
return new DashboardResponses.Metric(value,known,unit,status,unknown,reasons,null,null);
|
||||
}
|
||||
private static DashboardResponses.Distribution distribution(Map<String,Object> s,boolean available,AnalyticsResponses.Context c){
|
||||
List<DashboardResponses.Bucket> buckets=new ArrayList<>();
|
||||
for(String code:CoverageDistributionPolicy.BUCKETS){String suffix=code.toLowerCase(Locale.ROOT);long n=count(s,"bucket_"+suffix);BigDecimal physical=decimal(s,"physical_"+suffix);
|
||||
if(n==0&&available)physical=BigDecimal.ZERO;
|
||||
buckets.add(new DashboardResponses.Bucket(code,available?n:null,available?physical:null,available?count(s,"unknown_"+suffix):null));
|
||||
}
|
||||
Map<String,Long> reasons=new LinkedHashMap<>();s.forEach((key,value)->{if(key.startsWith("reason_")&&value!=null){long n=new BigDecimal(value.toString()).longValueExact();if(n>0)reasons.put(key.substring(7).toUpperCase(Locale.ROOT),n);}});
|
||||
String status=!available?"UNKNOWN":count(s,"bucket_unknown")>0?"PARTIAL":"COMPLETE";
|
||||
List<String> codes=!available?List.of("INVENTORY_UNAVAILABLE"):count(s,"bucket_unknown")>0?List.of("UNASSESSABLE_SKUS"):List.of();
|
||||
if(available&&!"VALID".equals(c.inventoryStatus())){status="PARTIAL";codes=List.of("INVENTORY_"+c.inventoryStatus());}
|
||||
return new DashboardResponses.Distribution(CoverageDistributionPolicy.RULE_VERSION,available?count(s,"candidate_skus"):null,
|
||||
available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of());
|
||||
}
|
||||
private static DashboardResponses.Ranking ranking(String kind,Map<String,Object> s,List<DashboardReadMapper.Ranked> rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c){
|
||||
String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";};
|
||||
List<DashboardReadMapper.Ranked> selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator
|
||||
.comparing(DashboardReadMapper.Ranked::mainValue,Comparator.reverseOrder())
|
||||
.thenComparing(r->"HOT".equals(kind)?r.orderCount():0L,Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList();
|
||||
var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS",
|
||||
"HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(),
|
||||
divide30("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d()),r.coverageDays())).toList():List.<DashboardResponses.Item>of();
|
||||
long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE";
|
||||
List<String> reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of();
|
||||
if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS");
|
||||
if(available&&"HOT".equals(kind)&&!"VALID".equals(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());}
|
||||
return new DashboardResponses.Ranking(from,until,30,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons);
|
||||
}
|
||||
private static Map<String,Object> normalize(Map<String,Object> source){Map<String,Object> result=new HashMap<>();if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));return result;}
|
||||
private static BigDecimal decimal(Map<String,Object> source,String key){Object value=source.get(key);return value==null?null:value instanceof BigDecimal decimal?decimal:new BigDecimal(value.toString());}
|
||||
private static long count(Map<String,Object> source,String key){BigDecimal value=decimal(source,key);return value==null?0:value.longValueExact();}
|
||||
private static BigDecimal divide30(BigDecimal value){return value==null?null:value.divide(BigDecimal.valueOf(30),12,RoundingMode.HALF_UP);}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface DashboardReadMapper {
|
||||
record Day(LocalDate businessDate,BigDecimal observedQuantity,Long invalidRowCount){}
|
||||
record Ranked(String rankCode,String customerCode,String skuCode,String productName,BigDecimal mainValue,
|
||||
BigDecimal ownerOutbound,Long orderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,
|
||||
BigDecimal frozenQuantity,BigDecimal defectivePhysicalQuantity,BigDecimal outboundQuantity30d,BigDecimal coverageDays){}
|
||||
@Select("SELECT business_date,SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN outbound_quantity END) observed_quantity,"
|
||||
+"COUNT(CASE WHEN quantity_unit IS NULL OR quantity_unit<>'PCS' OR outbound_quantity IS NULL OR outbound_quantity<0 THEN 1 END) invalid_row_count "
|
||||
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND source_account=#{p.account} AND warehouse_code=#{p.warehouse} "
|
||||
+"AND ((business_date>=#{chartFrom} AND business_date<#{chartUntil}) OR (business_date>=#{p.from} AND business_date<#{p.until})) GROUP BY business_date ORDER BY business_date")
|
||||
List<Day> daily(@Param("p") AnalyticsSqlRequest request,@Param("chartFrom") LocalDate chartFrom,@Param("chartUntil") LocalDate chartUntil);
|
||||
@SelectProvider(type=DashboardReadSql.class,method="statistics")
|
||||
Map<String,Object> statistics(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
|
||||
@SelectProvider(type=DashboardReadSql.class,method="rankings")
|
||||
List<Ranked> rankings(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Three bounded aggregate queries: warehouse daily points, one statistics row, at most 30 ranked rows. */
|
||||
public final class DashboardReadSql {
|
||||
private DashboardReadSql(){}
|
||||
public static String statistics(Map<String,Object> args){
|
||||
var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(AnalyticsInventorySql.cte(p));
|
||||
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 result r) 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")
|
||||
.append(",COUNT(CASE WHEN owner_outbound>0 THEN 1 END) hot_eligible,COUNT(CASE WHEN owner_outbound IS NULL THEN 1 END) hot_unknown")
|
||||
.append(",COUNT(CASE WHEN ").append(CoverageDistributionSql.finitePositive()).append(" THEN 1 END) long_eligible")
|
||||
.append(",COUNT(CASE WHEN available_quantity IS NULL OR (available_quantity>0 AND outbound_quantity30d IS NULL) THEN 1 END) long_unknown")
|
||||
.append(",COUNT(CASE WHEN no_outbound=1 THEN 1 END) no_outbound_eligible,COUNT(CASE WHEN no_outbound IS NULL THEN 1 END) no_outbound_unknown");
|
||||
for(String code:CoverageDistributionPolicy.BUCKETS){String alias=code.toLowerCase(java.util.Locale.ROOT);
|
||||
sql.append(",COUNT(CASE WHEN bucket_code='").append(code).append("' THEN 1 END) bucket_").append(alias)
|
||||
.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"))
|
||||
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));
|
||||
return AnalyticsInventorySql.typed(sql+" FROM classified");
|
||||
}
|
||||
public static String rankings(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");
|
||||
String sql=AnalyticsInventorySql.cte(p)
|
||||
+", 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)"
|
||||
+", long_rank AS (SELECT * FROM result WHERE "+CoverageDistributionSql.finitePositive()+" ORDER BY coverage_days DESC,sku_code ASC,customer_code ASC LIMIT 10)"
|
||||
+", no_rank AS (SELECT * FROM result WHERE no_outbound=1 ORDER BY physical_quantity DESC,sku_code ASC,customer_code ASC LIMIT 10) ";
|
||||
sql+=select("HOT","owner_outbound","hot_rank")+" UNION ALL "+select("LONG","coverage_days","long_rank")+" UNION ALL "+select("NO_OUTBOUND","physical_quantity","no_rank");
|
||||
return AnalyticsInventorySql.typed(sql);
|
||||
}
|
||||
private static String select(String kind,String value,String table){return "SELECT '"+kind+"' rank_code,customer_code,sku_code,product_name,"+value+" main_value,owner_outbound,order_count,physical_quantity,available_quantity,frozen_quantity,defective_physical_quantity,outbound_quantity30d,coverage_days FROM "+table;}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.util.Set;
|
||||
|
||||
public final class DashboardRequest {
|
||||
private DashboardRequest(){}
|
||||
public static void validateParameterNames(Set<String> names){
|
||||
if(!Set.of("sourceAccount","warehouseCode","rangePreset","startDate","endDate","bucket","salesRunId","inventoryRunId","salesScopeHash","inventoryScopeHash","queryAsOf").containsAll(names))
|
||||
throw new BizException("经营总览不接受货主、SKU、分页、任意榜单数量或其他筛选条件");
|
||||
}
|
||||
public static AnalyticsQuery normalize(AnalyticsQuery q){
|
||||
if(q.customerCode()!=null||q.sku()!=null||q.current()!=null||q.size()!=null||q.windowDays()!=null)throw new BizException("经营总览仅支持仓库、趋势范围与固定版本上下文");
|
||||
return new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,q.rangePreset()==null?"LAST_15_DAYS":q.rangePreset(),q.startDate(),q.endDate(),q.bucket(),null,null,null,
|
||||
q.salesRunId(),q.inventoryRunId(),q.salesScopeHash(),q.inventoryScopeHash(),q.queryAsOf());
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
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 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 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,
|
||||
List<Bucket> buckets,String dataStatus,List<String> reasonCodes,Map<String,Long> unknownReasons){}
|
||||
public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal outboundQuantity30d,
|
||||
Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,
|
||||
BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays){}
|
||||
public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount,
|
||||
Long unassessableCount,List<Item> items,String dataStatus,List<String> reasonCodes){}
|
||||
public record Dashboard(Context context,Kpis kpis,SalesTrend salesTrend,Distribution coverageDistribution,
|
||||
Ranking hotSkus,Ranking longCoverageSkus,Ranking noOutboundSkus){}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */
|
||||
public final class DashboardSalesPolicy {
|
||||
private DashboardSalesPolicy(){}
|
||||
public static List<SalesBucketPolicy.Point> points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List<DashboardReadMapper.Day> rows){
|
||||
Map<LocalDate,DashboardReadMapper.Day> days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row);
|
||||
List<SalesBucketPolicy.Point> result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai");
|
||||
for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){
|
||||
var actual=days.get(day);Instant start=day.atStartOfDay(zone).toInstant(),end=day.plusDays(1).atStartOfDay(zone).toInstant();
|
||||
Instant lower=c.salesCoveredFrom()==null?null:(c.salesCoveredFrom().isAfter(start)?c.salesCoveredFrom():start);
|
||||
Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end);
|
||||
boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper);
|
||||
boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0;
|
||||
boolean trusted="VALID".equals(c.salesStatus())&&!c.rebuildRequired();
|
||||
boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&c.completeDayFrom()!=null&&c.completeDayUntil()!=null
|
||||
&&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end);
|
||||
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(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));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -84,7 +84,7 @@ public class SelfopDiscoveryService {
|
||||
});
|
||||
}
|
||||
}
|
||||
route(task,manifest,lease);
|
||||
route(task,manifest,lease,false);
|
||||
for(var entry:manifest.getObjects().entrySet()) {
|
||||
var ref=entry.getValue();
|
||||
if(ref.getRefreshTaskId()!=null){
|
||||
@@ -157,8 +157,11 @@ public class SelfopDiscoveryService {
|
||||
SelfopProgressProjection.freezeRun(finalFetchTask,finalFetchRun);finalFetchRun.setStatus("ROUTED");finalFetchRun.setFetchFinishedAtUtc(utcNow());finalFetchRun.setFinishedAtUtc(utcNow());finalFetchRun.setUpdatedAt(utcNow());runs.updateById(finalFetchRun);
|
||||
finalFetchTask.setStatus("SUCCEEDED");finalFetchTask.setUpdatedAt(utcNow());tasks.updateById(finalFetchTask);}
|
||||
});
|
||||
route(task,manifest,lease);
|
||||
route(task,manifest,lease,false);
|
||||
}
|
||||
// A directory warehouse is provisional until every account detail (including resumed
|
||||
// revision checks) has resolved. An empty warehouse must not publish a false zero early.
|
||||
route(task,manifest,lease,true);
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow();
|
||||
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);
|
||||
run.setStatus("ROUTED");run.setCoverageStatus("COMPLETE");run.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2);
|
||||
@@ -202,11 +205,12 @@ public class SelfopDiscoveryService {
|
||||
manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1),
|
||||
manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);}
|
||||
tasks.updateById(task);}
|
||||
private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease){
|
||||
private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease,boolean detailsResolved){
|
||||
// Refresh commands only replace a parent manifest reference; never create an independent
|
||||
// fact writer that could publish around that parent's half-applied barrier.
|
||||
if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return;
|
||||
tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return;
|
||||
boolean accountDetailsReady=detailsResolved&&manifest.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null);
|
||||
for(var scope:scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){
|
||||
if(!config.getWarehouses().containsKey(scope.getWarehouseCode()))continue;
|
||||
var own=new SelfopRoutingManifest();own.setDirectoryComplete(true);own.setTotal(0L);own.setPages(manifest.getPages());
|
||||
@@ -220,8 +224,8 @@ public class SelfopDiscoveryService {
|
||||
}
|
||||
}
|
||||
own.setTotal((long)own.getObjects().size());
|
||||
boolean ready=scope.getSourceTimezone()!=null&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone()))
|
||||
&&own.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null);
|
||||
boolean ready=accountDetailsReady&&scope.getSourceTimezone()!=null
|
||||
&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone()));
|
||||
var child=new SelfopSyncTask();child.setSourceAccount(parent.getSourceAccount());child.setTaskRole("WAREHOUSE_SYNC");child.setScopeId(scope.getId());child.setWarehouseCode(scope.getWarehouseCode());
|
||||
child.setExecutionPurpose(parent.getExecutionPurpose());
|
||||
child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline());
|
||||
|
||||
Reference in New Issue
Block a user