From 83dc6dea3f4b4e5af60a15302096c07aa041ee7a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 11:03:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../PlatformAnalyticsController.java | 2 +- .../mapper/SelfopCoordinationMapper.java | 4 +- .../query/AnalyticsInventoryOptions.java | 6 +- .../query/AnalyticsInventorySql.java | 20 ++--- .../query/AnalyticsQueryService.java | 5 +- .../query/CoverageDistributionPolicy.java | 2 +- .../query/CoverageDistributionSql.java | 14 ++-- .../query/DashboardQueryService.java | 23 +++--- .../analytics/query/DashboardReadSql.java | 4 +- .../analytics/query/DashboardResponses.java | 7 +- .../query/InventoryTurnoverQueryService.java | 2 +- .../query/InventoryTurnoverRequest.java | 7 +- .../analytics/query/InventoryTurnoverSql.java | 30 ++++++-- .../query/InventoryWindowPolicy.java | 8 +- .../openapi/self-operated-analytics-v1.yaml | 30 ++++---- .../self-operated-inventory-turnover-v1.yaml | 4 +- .../query/AnalyticsQueryServiceTest.java | 18 ++++- .../query/AnalyticsSqlSemanticTest.java | 6 +- .../analytics/query/DashboardPolicyTest.java | 5 +- .../query/DashboardQueryServiceTest.java | 17 +++-- .../query/DashboardSqlSemanticTest.java | 38 +++++++++- .../query/InventoryTurnoverContextTest.java | 23 ++++++ .../query/InventoryTurnoverSqlTest.java | 59 ++++++++++++++ .../query/InventoryWindowPolicyTest.java | 11 ++- .../sync/SelfopContinuousDueSqlTest.java | 76 +++++++++++++++++++ 26 files changed, 325 insertions(+), 98 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java diff --git a/AGENTS.md b/AGENTS.md index c4a00bd25..3fddbd721 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java index 61ea808c1..d03f9f64a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java @@ -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> inventoryCoverage( @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java index b6465397d..b324ee8e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -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 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 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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java index ba76bb012..1d2f068d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java @@ -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("货主编码前缀不能为空或超长"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java index a96458476..a7877d253 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -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 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 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){ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java index af36ee5a3..306a38d7b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -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 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()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java index 4f756c276..954331268 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java @@ -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 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 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("库存分布桶或规则版本无效"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java index 986a98174..19c04df8e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java @@ -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 ""; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java index 6bfeadc03..7786c7fd2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java @@ -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 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 hotStats=stats;List 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 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 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);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java index f1634697a..980e406d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java @@ -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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java index cc677886d..09f01b104 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java @@ -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 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 points){} public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){} public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java index 5cbfe4ca8..a1140f22d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java @@ -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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java index a65cdf97a..e8fb8e892 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -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"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 12d76479b..25eacdbbd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -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 args) { Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java index 376e45092..9dd6d3928 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java @@ -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); diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index 37711e475..2ec0723fb 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -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} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index 386d0c90c..01cffcabd 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -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}} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java index 854d15af4..59c35921b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java @@ -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){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java index be6937aa7..b799f412f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -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 { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java index 585c2628d..ae2fff259 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java @@ -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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java index 23a3c6784..d350178cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java @@ -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){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index dff6165bf..38eb6f4c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -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 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{ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java index f4f64651e..8164b0fd5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 053b3bf6f..c08b433df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -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_dateq.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_dater.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> reportRows(InventoryTurnoverSql.Input p) throws Exception { var result=new ArrayList>();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"))) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java index a936e9016..54ac47fb0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java @@ -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() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java new file mode 100644 index 000000000..ab082a463 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java @@ -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 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();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)); + } +}