生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27

Merged
admin merged 138 commits from test into main 2026-10-16 18:02:59 +08:00
26 changed files with 325 additions and 98 deletions
Showing only changes of commit 83dc6dea3f - Show all commits
+1 -1
View File
@@ -78,7 +78,7 @@
| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` |
| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` |
| Lastleg 内部模块化与依赖基线 | `lastleg-service/` |
| 自营领星采集、经营统计、SKU 日销量 | `integration-service/` 采集适配、`lastleg-service/selfoperated/` 查询;按实际链路只读相关模块 |
| 自营领星采集、经营统计、SKU 日销量 | `lastleg-service/selfoperated/` 查询与采集编排;领星签名/分页/外部接口查 `integration-service/`,不因采集页面任务同时读取两服务 |
| 跨客户同仓预约、预约跟进、到港日历 | `lastleg-service/inboundplan/`、`appointmentplanning/`;到港事实查 `firstleg/portschedule/` |
| 车辆、司机证照、车次、外接预订、车队报价、出车记录 | `lastleg-service/fleet/`;先读 `lastleg-service/AGENTS.md` 对应入口,客户排车关联再沿 `dispatch/fleet/` 实际调用 |
| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` |
@@ -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));
@@ -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);
@@ -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("货主编码前缀不能为空或超长");
@@ -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){
@@ -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());
@@ -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("库存分布桶或规则版本无效");
@@ -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 "";
}
@@ -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);}
}
@@ -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")
@@ -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,
@@ -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);
}
@@ -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");
@@ -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());
@@ -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);
@@ -13,7 +13,7 @@ info:
默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。
每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。
SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。
热销榜跟随图表所选日期,以已采集有效出库量排序;覆盖不足标记PARTIAL,不改变30日日均分母;不触发领星请求。
热销榜跟随图表所选日期,以已采集有效出库量排序;覆盖不足标记PARTIAL,日均、库存分布及两库存榜使用同一所选区间的完整销量与自然日数;不触发领星请求。
运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。
阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。
total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。
@@ -26,7 +26,7 @@ paths:
summary: 单仓固定版本经营总览
description: >-
平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。
默认LAST_15_DAYS同时用于趋势、区间出库和热销Top10;热销按已采集有效量排序并标记覆盖状态。日均、互斥分桶及两个库存Top10固定至昨日30完整日。
默认LAST_15_DAYS统一用于趋势、区间出库、日均、互斥分桶及三个Top10;热销按已采集有效量排序并标记覆盖状态,库存评估要求所选区间完整销量及观察资格。
不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。
库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。
parameters:
@@ -182,9 +182,12 @@ paths:
'403': {description: 非平台身份或缺少独立只读权限}
/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage:
get:
summary: 正品库存与固定10或30日可售天数
description: '固定10或30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
summary: 正品库存与所选窗口可售天数
description: '普通库存明细使用10或30天;总览严格下钻须携带drilldownFrom=DASHBOARD及原图CUSTOM起止日期,不得同时携带salesWindowDays/salesWindowEndDate。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
parameters:
- {$ref: '#/components/parameters/rangePreset'}
- {$ref: '#/components/parameters/startDate'}
- {$ref: '#/components/parameters/endDate'}
- {$ref: '#/components/parameters/coverageBucket'}
- {$ref: '#/components/parameters/bucketRuleVersion'}
- {$ref: '#/components/parameters/coverageSelection'}
@@ -384,13 +387,13 @@ components:
name: coverageBucket
in: query
description: 与非ALL attention和coverageSelection互斥,须配套固定规则版本;SQL分页前采用与总览相同互斥谓词。
schema: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_30D, NO_AVAILABLE, UNKNOWN]}
schema: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_IN_WINDOW, NO_AVAILABLE, UNKNOWN]}
bucketRuleVersion: {name: bucketRuleVersion, in: query, description: 不带coverageBucket时禁止单独传入, schema: {type: string, enum: [COVERAGE_DISTRIBUTION_V1]}}
coverageSelection: {name: coverageSelection, in: query, description: 正式有限正可售天数候选,在SQL分页前过滤,与非ALL attention及coverageBucket互斥, schema: {type: string, enum: [FINITE_POSITIVE]}}
drilldownFrom: {name: drilldownFrom, in: query, description: 必须固定原版本,服务端在真实库存有效期与查询到期较早者阻断并要求刷新总览;不增加权限, schema: {type: string, enum: [DASHBOARD]}}
keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页}
attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}}
sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_30D_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序}
attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}}
sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序}
coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策}
sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
@@ -425,9 +428,6 @@ components:
- {$ref: '#/components/schemas/Context'}
- type: object
properties:
fixedWindowStartDate: {type: string, format: date}
fixedWindowEndDateExclusive: {type: string, format: date}
fixedWindowDays: {type: integer, enum: [30]}
inventoryFreshUntil: {type: string, format: date-time, nullable: true}
inventoryDrilldownExpiresAt: {type: string, format: date-time, nullable: true}
DashboardMetric:
@@ -445,11 +445,11 @@ components:
type: object
properties:
intervalOutboundQuantity: {$ref: '#/components/schemas/DashboardMetric'}
averageDailyOutboundQuantity30d: {$ref: '#/components/schemas/DashboardMetric'}
averageDailyOutboundQuantity: {$ref: '#/components/schemas/DashboardMetric'}
physicalQuantity: {$ref: '#/components/schemas/DashboardMetric'}
availableQuantity: {$ref: '#/components/schemas/DashboardMetric'}
physicalSkuCount: {$ref: '#/components/schemas/DashboardMetric'}
noOutboundSkuCount30d: {$ref: '#/components/schemas/DashboardMetric'}
noOutboundSkuCount: {$ref: '#/components/schemas/DashboardMetric'}
CoverageDistribution:
type: object
description: 数值桶按未舍入交叉乘积判断;七桶互斥守恒。库存版未发布时计数为空,不把销量候选伪造成库存总量。
@@ -467,7 +467,7 @@ components:
items:
type: object
properties:
bucketCode: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_30D, NO_AVAILABLE, UNKNOWN]}
bucketCode: {type: string, enum: [DAYS_0_30, DAYS_30_60, DAYS_60_90, DAYS_GT_90, NO_OUTBOUND_IN_WINDOW, NO_AVAILABLE, UNKNOWN]}
skuCount: {type: string, nullable: true}
knownPhysicalQuantity: {type: number, nullable: true}
unknownPhysicalSkuCount: {type: string, nullable: true}
@@ -493,7 +493,7 @@ components:
properties:
windowStartDate: {type: string, format: date}
windowEndDateExclusive: {type: string, format: date}
calculationWindowDays: {type: integer, minimum: 1, description: 热销为所选日期天数;库存榜固定30}
calculationWindowDays: {type: integer, minimum: 1, description: 三个榜单均为所选日期区间的自然日数}
eligibleCount: {type: string, nullable: true}
unassessableCount: {type: string, nullable: true}
items: {type: array, maxItems: 10, items: {$ref: '#/components/schemas/DashboardRankingItem'}}
@@ -658,7 +658,7 @@ components:
status: {type: string, nullable: true}
reasonCodes: {type: array, items: {type: string}}
InventoryMetric:
description: 库存独立窗口响应。windowOutboundQuantity为所选完整10或30日销量;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。30日专属筛选及总览下钻不能用于10日或历史截止窗口。
description: 库存窗口响应。windowOutboundQuantity为所选完整窗口销量:普通明细10或30日,总览严格下钻使用原图CUSTOM区间;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。无出库关注、出库排序及分组均使用实际窗口。
type: object
properties:
customerName: {type: string, nullable: true}
@@ -39,8 +39,8 @@ paths:
- {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}}
- name: sortBy
in: query
description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。使用正式值及未舍入值排序。'
schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_DESC, AVAILABLE_DESC, OUTBOUND_7_DESC, OUTBOUND_15_DESC, OUTBOUND_30_DESC, COVERAGE_ASC, COVERAGE_DESC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]}
description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。全量筛选后按正式未舍入值排序再分页,未知值始终末尾,同值按SKU及货主升序。AVERAGE_DAILY使用baseWindowDays指定的日均窗口。'
schema: {type: string, default: PHYSICAL_DESC, enum: [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]}
- {name: current, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
- {name: includeOverview, in: query, schema: {type: boolean, default: true}}
@@ -339,15 +339,29 @@ class AnalyticsQueryServiceTest {
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null,null,null)));
verifyNoInteractions(mapper);
}
@Test void dashboardInventoryUsesExactSelectedDatesInsteadOfDefaultThirtyDays(){
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE","DASHBOARD",null,null);
for(int days:List.of(1,7,15,45)){
clearInvocations(mapper);
var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(days),D.minusDays(1),null,null,1,20,11L,22L,SH,IH,NOW);
var response=service.inventoryCoverage(q,o);
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).inventoryPage(cap.capture(),eq(o));
assertEquals(D.minusDays(days),cap.getValue().from());assertEquals(D,cap.getValue().until());
assertEquals(D.minusDays(days),response.context().rangeStartDate());assertEquals(D,response.context().rangeEndDateExclusive());
assertEquals(days,response.context().calculationWindowDays());
assertThrows(BizException.class,()->service.inventoryCoverage(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,null)));
}
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o));
}
@Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD",null,null);
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o));
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
var q=pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1);
var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(7),D.minusDays(1),null,null,1,20,11L,22L,SH,IH,NOW.minusSeconds(60));
assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->service.inventoryCoverage(q,o)).getMessage());
verify(mapper,never()).inventoryCount(any(),any());verify(mapper,never()).inventoryPage(any(),any());
// Normal inventory diagnostics still return the same fixed but stale version.
assertEquals("STALE",service.inventoryCoverage(q,options()).context().inventoryStatus());
assertEquals("STALE",service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1),options()).context().inventoryStatus());
}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);}
private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){
@@ -256,12 +256,12 @@ public class AnalyticsSqlSemanticTest {
var row=inventory(null,null,null,0,20).get(0);
assertDecimal("23",row.physicalQuantity());assertDecimal("10",row.availableQuantity());assertNull(row.frozenQuantity());
assertNull(row.defectivePhysicalQuantity());assertEquals(1,row.noOutbound());assertNull(row.coverageDays());
assertEquals(1,inventory("NO_OUTBOUND_30D",null,null,0,20).size());
assertEquals(1,inventory("NO_OUTBOUND_IN_WINDOW",null,null,0,20).size());
}
@Test void frozenDefectiveOnlyStockMatchesNoOutboundEvenWhenGoodAvailableIsZero() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
stockExact("SKU",0,"0","0","0","0","VALID",null);stockExact("SKU",1,"40","0","40","0","VALID",null);
var row=inventory("NO_OUTBOUND_30D",null,null,0,20).get(0);
var row=inventory("NO_OUTBOUND_IN_WINDOW",null,null,0,20).get(0);
assertDecimal("0",row.coverageDays());assertEquals(1,row.noOutbound());assertEquals(1,inventory("FROZEN_STOCK",null,null,0,20).size());
assertEquals(1,inventory("DEFECTIVE_STOCK",null,null,0,20).size());assertTrue(inventory("COVERAGE_GT_THRESHOLD",1,null,0,20).isEmpty());
}
@@ -396,7 +396,7 @@ public class AnalyticsSqlSemanticTest {
var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null,null,null);
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",bucket)).contains("stock_paged"));
assertEquals(List.of("B/A","C/A"),inventoryRows(p,bucket).stream().map(r->r.customerCode()+"/"+r.skuCode()).toList());
for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_30D_ASC"))
for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_ASC"))
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged"));
}
@Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception {
@@ -20,7 +20,8 @@ class DashboardPolicyTest {
}
static final LocalDate DAY=LocalDate.of(2026,10,13);
static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z");
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());}
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return context(coveredFrom,coveredUntil,inventory,DAY.minusDays(14),DAY.plusDays(1));}
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory,LocalDate from,LocalDate until){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,from,until,null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());}
@Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){
var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L));
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows,java.util.List.of());var summary=SalesBucketPolicy.aggregate(points);
@@ -76,7 +77,7 @@ class DashboardPolicyTest {
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null,null,null),
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_IN_WINDOW",null,null,null,null,"FINITE_POSITIVE",null,null,null),
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null,null,null)))assertThrows(BizException.class,()->o.validate("INVENTORY",query(true)));
assertThrows(BizException.class,()->DashboardRequest.validateParameterNames(Set.of("sourceAccount","warehouseCode","topN")));
assertEquals("LAST_15_DAYS",DashboardRequest.normalize(query(false)).rangePreset());
@@ -12,7 +12,7 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class DashboardQueryServiceTest {
@Test void oneContextKeepsThirtyDaySalesAndHotAvailableWhenInventoryUnavailable()throws Exception{
@Test void selectedWindowKeepsHotAvailableButDoesNotInventAverageWhenIncomplete()throws Exception{
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
@@ -27,11 +27,12 @@ class DashboardQueryServiceTest {
reportRow.putAll(Map.of("rank_code","HOT","customer_code","A","sku_code","X","product_name","Product","main_value",new BigDecimal("30"),"owner_outbound",new BigDecimal("30"),"order_count",1L));
when(mapper.report(any(),any())).thenReturn(List.of(reportRow));
var result=service.dashboard(q);
assertEquals(0,BigDecimal.ONE.compareTo(result.kpis().averageDailyOutboundQuantity30d().value()));
assertNull(result.kpis().averageDailyOutboundQuantity().value());
assertEquals(15,result.hotSkus().calculationWindowDays());assertEquals("PARTIAL",result.hotSkus().dataStatus());assertNull(result.hotSkus().items().get(0).averageDailyOutboundQuantity());
assertEquals(200L,result.hotSkus().eligibleCount());assertEquals(1,result.hotSkus().items().size());assertEquals("A",result.hotSkus().items().get(0).customerCode());
assertNull(result.coverageDistribution().candidateSkuCount());assertTrue(result.coverageDistribution().buckets().stream().allMatch(b->b.skuCount()==null));
assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(30),result.context().fixedWindowStartDate());
assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(14),result.context().versionContext().rangeStartDate());
assertEquals(15,result.longCoverageSkus().calculationWindowDays());assertEquals(15,result.noOutboundSkus().calculationWindowDays());
verify(mapper).daily(any(),any(),any());verify(mapper,times(2)).report(any(),any());verifyNoMoreInteractions(mapper);
var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext"));
assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly());
@@ -40,8 +41,8 @@ class DashboardQueryServiceTest {
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
var range=SalesRangePolicy.resolve("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false,day.minusDays(15),day);
var range=SalesRangePolicy.resolve("CUSTOM",day.minusDays(15),day.minusDays(1),"DAY",now,ZoneId.of("Asia/Shanghai"),366);
for(Boolean ready:Arrays.asList(false,null)){
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
var error=assertThrows(com.baiyue.common.core.exception.BizException.class,()->service.dashboard(q));
@@ -53,11 +54,11 @@ class DashboardQueryServiceTest {
for(boolean salesPresent:List.of(true,false)){
var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot);
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day.minusDays(15),day,day,day.minusDays(30),day.minusDays(30),day,
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD",java.util.List.of()));
var result=service.dashboard(q);
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity().dataStatus());}
}
}
private static AnalyticsReadRows.Run salesRun(Boolean ready){
@@ -39,6 +39,40 @@ class DashboardSqlSemanticTest {
assertTrue(bound.getSql().endsWith("ORDER BY business_date"));
}
}
@Test void selectedDatesChangeDistributionLongestAndZeroOutboundTogetherWithExactDrilldowns()throws Exception{
fixture("RECENT","90","90","30");fixture("OLDER","60","60",null);
daily("C","OLDER",D.minusDays(20),"10","PCS");
// 7 complete recent days: RECENT=21 days to sell, OLDER has zero outbound.
// 30 complete days: RECENT=90, OLDER=180. Historical 7 days: only OLDER sold.
for(var period:List.of(new LocalDate[]{D.minusDays(7),D},new LocalDate[]{D.minusDays(30),D},new LocalDate[]{D.minusDays(21),D.minusDays(14)})){
int days=(int)java.time.temporal.ChronoUnit.DAYS.between(period[0],period[1]);
boolean historical=period[1].isBefore(D);
var p=windowRequest(period[0],period[1],true,"DASHBOARD");
var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()));
var ranks=rows.stream().map(row->{Map<String,Object> normalized=new HashMap<>();((Map<?,?>)row).forEach((k,v)->normalized.put(k.toString().toLowerCase(Locale.ROOT),v));return DashboardQueryService.ranked(normalized);}).toList();
var longest=ranks.stream().filter(r->"LONG".equals(r.rankCode())).max(Comparator.comparing(DashboardReadMapper.Ranked::mainValue)).orElseThrow();
assertEquals(days==30||historical?"OLDER":"RECENT",longest.skuCode());
assertEquals(0,new BigDecimal(days==30?"180":historical?"42":"21").compareTo(longest.mainValue()));
var zero=ranks.stream().filter(r->"NO_OUTBOUND".equals(r.rankCode())).map(DashboardReadMapper.Ranked::skuCode).toList();
assertEquals(days==30?List.of():List.of(historical?"RECENT":"OLDER"),zero);
for(String bucket:CoverageDistributionPolicy.BUCKETS){
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null);
var identities=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()),false,true).stream().map(Map.class::cast)
.filter(r->bucket.equals(r.get("BUCKET_CODE"))).map(r->r.get("SKU_CODE")).collect(java.util.stream.Collectors.toSet());
var detail=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",windowRequest(period[0],period[1],true,"INVENTORY"),"o",o))
.stream().map(AnalyticsReadRows.Inventory.class::cast).map(AnalyticsReadRows.Inventory::skuCode).collect(java.util.stream.Collectors.toSet());
assertEquals(identities,detail,bucket+" "+period[0]);
if("DAYS_0_30".equals(bucket))assertEquals(days==7&&!historical?Set.of("RECENT"):Set.of(),identities);
}
}
// Incomplete selection must not turn missing days into zero outbound.
var incomplete=query(DashboardReadMapper.class,"report",Map.of("p",windowRequest(D.minusDays(7),D.plusDays(1),false,"DASHBOARD"),"o",options()));
assertTrue(incomplete.stream().map(Map.class::cast).allMatch(r->r.get("RANK_CODE")==null));
assertEquals(2L,((Number)((Map<?,?>)incomplete.get(0)).get("BUCKET_UNKNOWN")).longValue());
}
private AnalyticsSqlRequest windowRequest(LocalDate from,LocalDate until,boolean complete,String kind){var p=request();
return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),from,until,D,from,p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,complete,true,true,0,20,kind,List.of());
}
@Test void selectedRangeHotUsesObservedSalesIncludingTodayAndMatchesTop100DespiteIncompleteCoverage()throws Exception{
fixture("RECENT","50","50","3");fixture("OLD","50","50",null);
daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS");
@@ -110,7 +144,7 @@ class DashboardSqlSemanticTest {
{"NINETY-ABOVE","90.000001","DAYS_GT_90"},{"LONG-A","100","DAYS_GT_90"},{"LONG-B","100","DAYS_GT_90"}};
for(var row:finite){fixture(row[0],row[1],row[1],"30");expected.get(row[2]).add("C/"+row[0]);}
for(int i=0;i<3;i++){
fixture("NOOUT-"+i,"100","100",null);expected.get("NO_OUTBOUND_30D").add("C/NOOUT-"+i);
fixture("NOOUT-"+i,"100","100",null);expected.get("NO_OUTBOUND_IN_WINDOW").add("C/NOOUT-"+i);
fixture("FROZEN-"+i,"50","0",null);expected.get("NO_AVAILABLE").add("C/FROZEN-"+i);
fixture("UNKNOWN-"+i,null,null,null);expected.get("UNKNOWN").add("C/UNKNOWN-"+i);
}
@@ -152,7 +186,7 @@ class DashboardSqlSemanticTest {
}
@Test void exactNinetyBoundaryAndUnknownInventoryRemainDistinct()throws Exception{
fixture("NINETY","90","90","30");fixture("NINETY04","90.04","90.04","30");fixture("INFINITE","999","999",null);
var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_30d"));
var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_in_window"));
assertEquals(2,count(s,"long_eligible"));assertTrue(ranks().stream().filter(r->r.rankCode().equals("LONG")).noneMatch(r->r.skuCode().equals("INFINITE")));
}
@Test void twoHundredHotCandidatesCountBeyondTopHundredAndKeepTopTenIdentical()throws Exception{
@@ -63,6 +63,8 @@ class InventoryTurnoverContextTest {
var c=contexts.resolve(request(null)).response();
assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId());
assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus());
var absent=InventoryTurnoverSql.chartInput(request(null),contexts.resolve(request(null)));
assertFalse(absent.salesKnown());assertTrue(absent.days().stream().allMatch(d->d.coverage()==0));
assertEquals(c,contexts.resolve(request(c.snapshotContext())).response());
when(analytics.scopes("demo","W")).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(null)));
@@ -81,6 +83,27 @@ class InventoryTurnoverContextTest {
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
var rebuilding=contexts.resolve(request(null));
assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0));
assertTrue(rebuilding.response().analyticsContext().rebuildRequired());
assertTrue(InventoryTurnoverSql.chartInput(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==1));
}
@Test void publishedChartRetainsEighteenCoveredDaysWhileCurrentAssessmentsWaitForRebuild() {
var published=run(11,1,SH);
var eighteen=new Run(published.id(),published.taskId(),published.scopeId(),published.sourceAccount(),published.warehouseCode(),published.runRole(),
published.scopeHash(),published.scopeSnapshotJson(),published.status(),published.coverageStatus(),
END.minusDays(17).atStartOfDay().minusHours(8),published.coveredUntilUtc(),END.minusDays(17),published.completeDayUntil(),
published.fetchStartedAtUtc(),published.fetchFinishedAtUtc(),published.publishedAtUtc(),published.calculatedFactRevision(),null,null,null,"BUSINESS",true);
when(analytics.publishedRun(11L)).thenReturn(eighteen);
when(mapper.versionAt(eq(1L),eq(SH),any())).thenReturn(eighteen);
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
Map.of("section","OVERVIEW","payload","{\"sku_count\":0}")));
var result=new InventoryTurnoverQueryService(contexts,mapper,json).query(request(null));
assertTrue(result.context().analyticsContext().rebuildRequired());
verify(mapper).report(argThat(p->p.days().stream().allMatch(d->d.coverage()==0)));
verify(mapper).chartWindows(argThat(p->p.salesRun()==11L&&p.inventoryRun()==22L&&p.salesKnown()
&&p.days().stream().filter(d->d.coverage()==1).count()==18
&&p.days().subList(0,12).stream().allMatch(d->d.coverage()==0)
&&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1)));
}
@Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() {
var service=new InventoryTurnoverQueryService(contexts,mapper,json);
@@ -103,6 +103,23 @@ class InventoryTurnoverSqlTest {
assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed"));
decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count"));
}
@Test void eighteenPublishedDaysGiveSevenAndFifteenDayMeansWithoutInventingThirtyDays() throws Exception {
seed("A","SKU",0);
execute("DELETE FROM ll_selfop_sku_daily_sales WHERE business_date<DATE '"+END.minusDays(17)+"'");
// Unpublished/newer versions must not contribute to the selected report.
execute("INSERT INTO ll_selfop_sku_daily_sales VALUES (99,'demo','W','A','SKU',DATE '"+END+"',9999,'PCS')");
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
var chart=query("chartWindows",input(q,18,0,true)).get(0);
var windows=InventoryTurnoverPolicy.windows(chart,END.plusDays(1));
for(int i=0;i<2;i++) {
var window=windows.get(i);
assertEquals("COMPLETE",window.dataStatus());assertEquals(window.days(),window.completeDayCount());
decimal("10",window.averageDailyOutboundQuantity());
}
var thirty=windows.get(2);
assertEquals(18,thirty.completeDayCount());assertEquals(12,thirty.unknownDayCount());assertNull(thirty.averageDailyOutboundQuantity());
assertEquals("PARTIAL",thirty.dataStatus());
}
@Test void warehouseChartUsesSalesUnitsAndCoverageIndependentlyOfStockProfiles() throws Exception {
seed("A","SKU",30);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN'");
@@ -424,6 +441,48 @@ class InventoryTurnoverSqlTest {
var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);Object raw=row.get("payload");
return raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"PHYSICAL","AVAILABLE","FROZEN","DEFECTIVE","AVERAGE_PHYSICAL","TURNOVER","COVERAGE","OUTBOUND_7","OUTBOUND_15","OUTBOUND_30","AVERAGE_DAILY"})
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END");
for(String group:List.of("SKU","OWNER")) {
if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue;
for(String direction:List.of("ASC","DESC")) {
var q=request(group,"ALL",metric+"_"+direction,null,null,1,20);
assertDoesNotThrow(()->q.validate(END.plusDays(1)));
var p=input(q,30,30,true);
// Turnover divides sales by average stock, so its ordering reverses here.
boolean lowFirst=direction.equals("ASC")!=metric.equals("TURNOVER");
var expected=lowFirst?List.of("L","H","U"):List.of("H","L","U");
assertEquals(expected,reportRows(p).stream().map(r->r.get("customer_code")).toList(),group+" "+q.sort());
assertEquals(expected,query("page",p).stream().map(r->r.get("customer_code")).toList());
}
}
}
@Test void averageDailySortUsesSelectedWindowBeforePaginationAndDoesNotSortObservedSubtotals() throws Exception {
seed("A","RECENT",0);seed("B","OLDER",0);seed("C","UNKNOWN",0);
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN sku_code='RECENT' THEN CASE WHEN business_date>=DATE '"+END.minusDays(6)+"' THEN 10 ELSE 0 END ELSE CASE WHEN business_date<DATE '"+END.minusDays(6)+"' THEN 20 ELSE 0 END END");
execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'");
for(int days:List.of(7,15,30)) {
for(int page=1;page<=3;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context");
var expected=days==7?List.of("RECENT","OLDER","UNKNOWN"):List.of("OLDER","RECENT","UNKNOWN");
assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code"));
}
}
var q=request("SKU","ALL","OUTBOUND_30_ASC",null,null,1,20);
var rows=reportRows(input(q,15,0,true));
assertTrue(rows.stream().allMatch(r->r.get("w30_outbound")==null));
assertEquals(List.of("OLDER","RECENT","UNKNOWN"),rows.stream().map(r->r.get("sku_code")).toList());
}
@Test void rejectsUnsupportedSortNamesAndGroupingInsteadOfInterpolatingSql() {
for(String sort:List.of("physical ASC","PHYSICAL_ASC;DROP TABLE x","AVERAGE_DAILY","UNKNOWN_DESC"))
assertThrows(RuntimeException.class,()->request("SKU","ALL",sort,null,null,1,20).validate(END.plusDays(1)));
assertThrows(RuntimeException.class,()->request("OWNER","ALL","COVERAGE_ASC",null,null,1,20).validate(END.plusDays(1)));
assertThrows(RuntimeException.class,()->request("SKU","ALL","NO_OUTBOUND_PHYSICAL_DESC",null,null,1,20).validate(END.plusDays(1)));
}
private List<Map<String,Object>> reportRows(InventoryTurnoverSql.Input p) throws Exception {
var result=new ArrayList<Map<String,Object>>();var json=new com.fasterxml.jackson.databind.ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
for(var row:query("report",p)) if("ROW".equals(row.get("section")))
@@ -23,14 +23,13 @@ class InventoryWindowPolicyTest {
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY.plusDays(1)),DAY));
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(7,null),DAY));
}
@Test void tenDayAndHistoricalWindowsCannotMasqueradeAsThirtyDayDrilldowns() {
@Test void selectedWindowPredicatesWorkForTenDayAndHistoricalWindows() {
for(var o:new AnalyticsInventoryOptions[]{
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,null,"OUTBOUND_30D_ASC",null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_IN_WINDOW",null,null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,null,"OUTBOUND_ASC",null,null,null,null,null,10,null),
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,10,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,DAY.minusDays(2))})
assertThrows(BizException.class,()->InventoryWindowPolicy.range(o,DAY));
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null)})
assertEquals(10,InventoryWindowPolicy.range(o,DAY).dayCount());
assertThrows(BizException.class,()->options(10,null).validate("OVERVIEW",null));
}
@Test void tenDayFormulaKeepsMissingAndZeroSalesDistinctAndAvoidsIntermediateRounding() {
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
/** Executes the production scheduling predicates offline; only date arithmetic syntax is adapted to H2. */
class SelfopContinuousDueSqlTest {
private Connection db;
private MappedStatement statement;
private static final LocalDateTime MIDNIGHT = LocalDateTime.of(2026,10,14,16,0);
@BeforeEach void setup() throws Exception {
db=DriverManager.getConnection("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE","sa","");
execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,business_root_task_id BIGINT,source_account VARCHAR,execution_purpose VARCHAR,validation_root_task_id BIGINT,status VARCHAR,request_key VARCHAR,requested_by VARCHAR,task_role VARCHAR,task_type VARCHAR,next_retry_at_utc TIMESTAMP,planned_at_utc TIMESTAMP,query_until_utc TIMESTAMP,attempt_count INT)");
var config=new Configuration();config.addMapper(SelfopCoordinationMapper.class);
statement=config.getMappedStatement(SelfopCoordinationMapper.class.getName()+".dueContinuous");
}
@AfterEach void close() throws Exception {db.close();}
private void execute(String sql,Object...args) throws Exception {
try(var p=db.prepareStatement(sql)){for(int n=0;n<args.length;n++)p.setObject(n+1,args[n]);p.executeUpdate();}
}
private void inventory(long id,String status,int attempts,LocalDateTime planned,LocalDateTime retry) throws Exception {
execute("INSERT INTO ll_selfop_sync_task VALUES (?,?,'SYNTH-ACCOUNT','BUSINESS',NULL,?,?,'SELFOP_CONTINUOUS_PLANNER','WAREHOUSE_SYNC','SCHEDULED',?,?,NULL,?)",
id,id,status,"continuous:inventory:SYNTH-WH:"+planned,retry,planned,attempts);
}
private List<Long> due(LocalDateTime now) throws Exception {
var params=Map.of("now",now,"limit",20,"account","SYNTH-ACCOUNT");
var bound=statement.getBoundSql(params);
String sql=bound.getSql()
.replace("DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR)","DATEADD(HOUR,8,r.planned_at_utc)")
.replace("DATE_ADD(?,INTERVAL 8 HOUR)","DATEADD(HOUR,8,CAST(? AS TIMESTAMP))")
.replace("DATE_SUB(DATE(DATEADD(HOUR,8,CAST(? AS TIMESTAMP))),INTERVAL 8 HOUR)","DATEADD(HOUR,-8,DATE(DATEADD(HOUR,8,CAST(? AS TIMESTAMP))))");
try(var p=db.prepareStatement(sql)){
new DefaultParameterHandler(statement,params,bound).setParameters(p);
try(var rows=p.executeQuery()){var result=new ArrayList<Long>();while(rows.next())result.add(rows.getLong("id"));return result;}
}
}
@Test void retryAfterBeijingMidnightWaitsUntilBackoffThenRemainsDispatchable() throws Exception {
// Failure at 23:59:55 + the first 10-second backoff lands on the next Beijing day.
inventory(1,"RETRY_WAIT",1,MIDNIGHT.minusHours(16),MIDNIGHT.plusSeconds(5));
assertEquals(List.of(),due(MIDNIGHT.minusSeconds(5)));
assertEquals(List.of(),due(MIDNIGHT));
assertEquals(List.of(1L),due(MIDNIGHT.plusSeconds(5)));
assertEquals(List.of(1L),due(MIDNIGHT.plusDays(1)));
}
@ParameterizedTest @ValueSource(strings={"READY","RETRY_WAIT","RUNNING"})
void startedInventoryCanResumeAfterRestartAcrossMidnight(String status) throws Exception {
inventory(1,status,1,MIDNIGHT.minusHours(16),MIDNIGHT.minusSeconds(1));
assertEquals(List.of(1L),due(MIDNIGHT));
}
@Test void oldUnstartedSlotsAndTerminalOrUnauthorizedWorkRemainExcluded() throws Exception {
var yesterday=MIDNIGHT.minusHours(16);var today=MIDNIGHT.plusHours(8);
inventory(1,"READY",0,yesterday,null);
inventory(2,"DEAD",3,yesterday,null);
inventory(3,"SUCCEEDED",1,yesterday,null);
inventory(4,"READY",0,today,today);
inventory(5,"RETRY_WAIT",1,yesterday,null);
execute("UPDATE ll_selfop_sync_task SET source_account='OTHER-ACCOUNT' WHERE id=5");
inventory(6,"RETRY_WAIT",1,yesterday,null);
execute("UPDATE ll_selfop_sync_task SET execution_purpose='VALIDATION',validation_root_task_id=id WHERE id=6");
inventory(7,"RETRY_WAIT",1,yesterday,null);
execute("UPDATE ll_selfop_sync_task SET requested_by='untrusted' WHERE id=7");
inventory(8,"RETRY_WAIT",1,today.plusDays(1),null);
assertEquals(List.of(),due(MIDNIGHT));
assertEquals(List.of(4L),due(today));
}
}