From a41c271a67d4f38a01763b21f37c4f38dfab41f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 00:45:39 +0800 Subject: [PATCH] feat(selfop): add warehouse dashboard and exact inventory drilldowns --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 4 +- iam/AGENTS.md | 2 +- .../db/selfoperated-analytics-permissions.sql | 4 +- .../PlatformAnalyticsController.java | 9 +- .../query/AnalyticsInventoryOptions.java | 5 +- .../query/AnalyticsInventorySql.java | 27 +++- .../query/AnalyticsQueryService.java | 18 ++- .../analytics/query/AnalyticsReadSql.java | 54 ++++++- .../query/CoverageDistributionPolicy.java | 29 ++++ .../query/CoverageDistributionSql.java | 22 +++ .../query/DashboardQueryService.java | 90 ++++++++++++ .../analytics/query/DashboardReadMapper.java | 24 +++ .../analytics/query/DashboardReadSql.java | 38 +++++ .../analytics/query/DashboardRequest.java | 17 +++ .../analytics/query/DashboardResponses.java | 29 ++++ .../analytics/query/DashboardSalesPolicy.java | 33 +++++ .../sync/SelfopDiscoveryService.java | 14 +- .../openapi/self-operated-analytics-v1.yaml | 138 ++++++++++++++++++ .../PlatformAnalyticsControllerTest.java | 22 ++- .../query/AnalyticsQueryServiceTest.java | 20 ++- .../query/AnalyticsReadMapperTest.java | 19 ++- .../query/AnalyticsSqlSemanticTest.java | 52 +++++-- .../analytics/query/DashboardPolicyTest.java | 46 ++++++ .../query/DashboardQueryServiceTest.java | 37 +++++ .../query/DashboardSqlSemanticTest.java | 88 +++++++++++ .../sync/SelfopDiscoveryServiceTest.java | 32 ++++ 27 files changed, 827 insertions(+), 48 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 4c2dc155b..da5775a5d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -22,7 +22,7 @@ 内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 -平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、稳定任务号、阶段进度/心跳和旧版替代时间;库存 run 增加每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。本批只交付文件与离线验证,不执行 SQL、不部署;真实采集模式默认 OFF。 +平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、稳定任务号、阶段进度/心跳和旧版替代时间;库存 run 增加每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下经营总览、销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。本批只交付文件与离线验证,不执行 SQL、不部署;真实采集模式默认 OFF。 ## 唯一入口与区域边界 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5ec203f1e..5abf094fb 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8313,11 +8313,12 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND r.status='ACTIVE' AND r.deleted=0; --- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。 +-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。 USE baiyue_iam; SET NAMES utf8mb4; INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), @@ -8327,6 +8328,7 @@ INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VAL (4000001,5118000,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)), + (4000001,5118140,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)), (4000001,5118120,0,CURRENT_TIMESTAMP(3)), (4000001,5118130,0,CURRENT_TIMESTAMP(3)); diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 36de3dfaf..62248107c 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -17,7 +17,7 @@ | 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | -| 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;三个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 | +| 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下经营总览、销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;四个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | | 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | diff --git a/iam/src/main/resources/db/selfoperated-analytics-permissions.sql b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql index c6d6d1b49..46615ee1e 100644 --- a/iam/src/main/resources/db/selfoperated-analytics-permissions.sql +++ b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql @@ -1,8 +1,9 @@ --- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。 +-- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。 USE baiyue_iam; SET NAMES utf8mb4; INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES (5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118140,0,'PLATFORM','MENU',5118100,'platform:self-operated:overview:menu','经营总览','/self-operated/overview','GET',5,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), @@ -12,6 +13,7 @@ INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VAL (4000001,5118000,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)), + (4000001,5118140,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)), (4000001,5118120,0,CURRENT_TIMESTAMP(3)), (4000001,5118130,0,CURRENT_TIMESTAMP(3)); 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 2163f7e5b..bc10ee39f 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 @@ -19,7 +19,14 @@ public class PlatformAnalyticsController { public static final String READ_PERMISSION="platform:self-operated-analytics:list"; private final AnalyticsQueryService service; private final CollectionQueryService collection; - public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection){this.service=service;this.collection=collection;} + private final DashboardQueryService dashboard; + public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection,DashboardQueryService dashboard){this.service=service;this.collection=collection;this.dashboard=dashboard;} + @GetMapping("/dashboard") + @Operation(summary="同仓固定版本的经营总览图表与精确下钻上下文") + public ApiResponse dashboard(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query, + @RequestParam java.util.Map parameters){ + requirePlatform(user);DashboardRequest.validateParameterNames(parameters.keySet());return ApiResponse.success(dashboard.dashboard(query)); + } @GetMapping("/sku-sales-summary") @Operation(summary="五周期或历史自定义范围SKU累计清单") public ApiResponse> salesSummary( 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 b37960c7e..7f1145fe3 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 @@ -4,11 +4,14 @@ import com.baiyue.common.core.exception.BizException; import java.util.Set; /** Endpoint-specific inventory presentation, separate from immutable version/date identity. */ -public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold) { +public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold, + String coverageBucket,String bucketRuleVersion,String coverageSelection,String drilldownFrom) { public String attentionValue(){return attention==null?"ALL":attention;} public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;} public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} public void validate(String endpoint,AnalyticsQuery q){ + CoverageDistributionPolicy.validate(this); + if(!"INVENTORY".equals(endpoint)&&(coverageBucket!=null||coverageSelection!=null||drilldownFrom!=null))throw new BizException("分布下钻条件仅用于库存明细"); if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数"); if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效"); 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 fe092e308..6616b91a5 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 @@ -13,7 +13,9 @@ public final class AnalyticsInventorySql { +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);} private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} - private static String cte(AnalyticsSqlRequest p){ + private static String defectiveQuantity(){return "CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 " + +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END";} + static String cte(AnalyticsSqlRequest p){ return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," @@ -24,8 +26,7 @@ public final class AnalyticsInventorySql { +"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code)," +" stock AS (SELECT customer_code,sku_code,"+quantity("physical_quantity")+" physical_quantity,"+quantity("frozen_quantity")+" frozen_quantity," +quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity," - +"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 " - +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective_physical_quantity,MAX(observed_at_utc) observed_at_utc " + +defectiveQuantity()+" defective_physical_quantity,MAX(observed_at_utc) observed_at_utc " +"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," +" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count," +"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales " @@ -45,7 +46,10 @@ public final class AnalyticsInventorySql { +"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) "; } private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;} - private static String filter(AnalyticsInventoryOptions o){return switch(o.attentionValue()){ + private static String filter(AnalyticsInventoryOptions o){ + String distribution=CoverageDistributionSql.filter(o); + if(!distribution.isEmpty())return " WHERE "+distribution; + return switch(o.attentionValue()){ case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1"; case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1"; case "FROZEN_STOCK" -> " WHERE frozen_quantity>0"; @@ -68,7 +72,18 @@ public final class AnalyticsInventorySql { .replace("sku_code","k.sku_code").replace("customer_code","k.customer_code"); return typed(paged+"SELECT k.customer_code,k.sku_code,k.product_name,k.unit_valid,k.evidence_at_utc,k.physical_quantity,k.available_quantity,k.frozen_quantity,k.transit_quantity,k.defective_physical_quantity,k.observed_at_utc,k.owner_outbound,k.observed_outbound,k.order_count,l.last_outbound,k.observation_eligible,k.outbound_quantity30d,k.coverage_days,k.no_outbound,k.above_threshold,k.unassessable FROM paged k LEFT JOIN last_sales l ON l.customer_code=k.customer_code AND l.sku_code=k.sku_code ORDER BY "+outerOrder);} public static String inventoryCount(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); - return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));} + if(o.coverageBucket()==null&&o.coverageSelection()==null&&java.util.Set.of("FROZEN_STOCK","DEFECTIVE_STOCK").contains(o.attentionValue())) + return typed(stockAttentionCount(p,o)); + return typed("ALL".equals(o.attentionValue())&&o.coverageBucket()==null&&o.coverageSelection()==null?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));} + /** These predicates depend only on stock. Counting them must not aggregate sales + * or inspect the other profile version, even when those sales are unavailable. */ + private static String stockAttentionCount(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + String amount="FROZEN_STOCK".equals(o.attentionValue())?quantity("frozen_quantity"):defectiveQuantity(); + return "SELECT COUNT(*) FROM (SELECT customer_code,sku_code,"+amount+" amount FROM ll_selfop_inventory_snapshot " + +"WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code) st " + +"JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} " + +"AND i.customer_code=st.customer_code AND i.sku_code=st.sku_code WHERE #{p.stockFresh}=1 AND i.quantity_unit='PCS' AND st.amount>0"; + } public static String ownerTotals(Map args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count"); for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")) sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); @@ -78,5 +93,5 @@ public final class AnalyticsInventorySql { private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");} public static String owners(Map args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");} public static String ownerCount(Map args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");} - private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");} + static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");} } 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 b9199c9cb..603fa2a2d 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 @@ -25,7 +25,7 @@ public class AnalyticsQueryService { @Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7; @Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());} AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;} - private record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted, + record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted, LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){} public AnalyticsResponses.Page salesSummary(AnalyticsQuery q){ @@ -68,6 +68,7 @@ public class AnalyticsQueryService { public AnalyticsResponses.Page slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);} public AnalyticsResponses.Page inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){ o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY"); + CoverageDistributionPolicy.requireDrilldown(q,o,s.context(),inventoryFreshUntil(s),clock.instant()); long total=mapper.inventoryCount(p,o); return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of()); } @@ -134,7 +135,7 @@ public class AnalyticsQueryService { Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind); List statistics=mapper.rankingStats(p); long total=statistics.isEmpty()?0:statistics.get(0).total(); - List rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList(); + List rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind)).toList(); Map excluded=new TreeMap<>(); for(var e:statistics)if(e.reason()!=null)excluded.put(e.reason(),e.excludedCount()); return page(s,rows,total,excluded); @@ -148,7 +149,7 @@ public class AnalyticsQueryService { return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(), r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown); } - private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r){ + private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r,String kind){ Instant evidence=utc(r.evidenceAtUtc()); boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant()); boolean stockValid="VALID".equals(r.inventoryQuality()); @@ -161,7 +162,7 @@ public class AnalyticsQueryService { if(!"VALID".equals(result.status()))reasons.add(result.status()); if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); BigDecimal today=r.todayQuantity(); - if(today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){ + if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){ Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant(); Instant lower=evidence.isAfter(start)?evidence:start; if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom(); @@ -172,7 +173,7 @@ public class AnalyticsQueryService { stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(), r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of()); } - private Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){ + Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){ q.validate(chart,exact,hot); Instant now=clock.instant(); List scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode()); Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY"); @@ -233,7 +234,7 @@ public class AnalyticsQueryService { inventory==null?null:utc(inventory.fetchFinishedAtUtc()),salesState,inventoryState,rebuild,review,List.copyOf(reasons)); return new Snapshot(q,sales,inventory,known,"VALID".equals(salesState),fullFrom,fullUntil,range,c); } - private AnalyticsSqlRequest request(Snapshot s,String kind){ + AnalyticsSqlRequest request(Snapshot s,String kind){ int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset())); return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(), s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()), @@ -241,6 +242,11 @@ public class AnalyticsQueryService { s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()), s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind); } + Instant inventoryFreshUntil(Snapshot s){ + return s.inventory()==null||s.inventory().fetchFinishedAtUtc()==null?null: + utc(s.inventory().fetchFinishedAtUtc()).plusSeconds(threshold(config(s.inventory()))); + } + Instant queryClockNow(){return clock.instant();} private Run resolve(Scope scope,Long requested,String hash){ if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;} Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java index 51129fc4b..6277a2f3f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -29,7 +29,8 @@ public final class AnalyticsReadSql { return sql; } private static String cte(String kind){ - if("HOT".equals(kind)||"SLOW".equals(kind))return rankingCte(); + if("HOT".equals(kind))return hotSalesCte(); + if("SLOW".equals(kind))return rankingCte(); String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; String observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')"; String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')"; @@ -166,11 +167,62 @@ public final class AnalyticsReadSql { } public static String page(java.util.Map parameters){ var p=(AnalyticsSqlRequest)parameters.get("p"); + if("HOT".equals(p.kind()))return typed(hotPage(),p); return typed(cte(p.kind())+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," +"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity," +"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason " +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p); } + /** Rank on sales alone. Inventory enrichment and its quality checks touch only the + * selected page; inventory missing/invalid units cannot erase a valid sales rank. */ + private static String hotSalesCte(){ + String profileValid="(s.sku_code IS NULL OR s.quantity_unit='PCS')"; + String windowValid=profileValid+" AND COALESCE(m.window_valid,1)=1"; + String thirtyValid=profileValid+" AND COALESCE(m.thirty_valid,1)=1"; + String todayValid=profileValid+" AND COALESCE(m.today_valid,1)=1"; + return "WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER + +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+")," + +" metric_sales AS (SELECT customer_code,sku_code," + +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity," + +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders," + +"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," + +"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity END) today_quantity," + +"MIN(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid," + +"MIN(CASE WHEN business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid," + +"MIN(CASE WHEN business_date=#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) today_valid " + +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER + +"AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date0 ORDER BY "+order("HOT")+" LIMIT #{p.size} OFFSET #{p.offset}) " + +"SELECT k.customer_code,k.sku_code,COALESCE(i.product_name,s.product_name) product_name,'PCS' quantity_unit," + +evidence+" evidence_at_utc,k.unit_valid," + +"CAST(NULL AS DECIMAL(20,6)) observed_quantity,CAST(NULL AS SIGNED) sku_order_count,0 complete_day_count,0 partial_day_count,0 unknown_day_count," + +"k.window_quantity,k.window_sku_order_count,k.sales30_quantity,"+today+" today_quantity,st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc," + +"CASE WHEN st.sku_code IS NULL OR i.quantity_unit IS NULL OR i.quantity_unit<>'PCS' THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' " + +"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' " + +"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality," + +"CASE WHEN i.quantity_unit IS NULL OR i.quantity_unit<>'PCS' THEN 'UNIT_UNKNOWN_OR_CONFLICT' ELSE st.quality_reason END inventory_reason " + +"FROM paged k LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=k.customer_code AND s.sku_code=k.sku_code " + +"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=k.customer_code AND i.sku_code=k.sku_code " + +"LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} AND st.warehouse_code=#{p.warehouse} AND st.customer_code=k.customer_code AND st.sku_code=k.sku_code AND st.product_quality=0 " + +"ORDER BY k.window_quantity DESC,k.window_sku_order_count DESC,k.sku_code ASC,k.customer_code ASC"; + } public static String count(java.util.Map parameters){ var p=(AnalyticsSqlRequest)parameters.get("p"); // Candidate totals do not scan daily rows for normal lists. 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 new file mode 100644 index 000000000..4f756c276 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.time.Instant; +import java.util.List; + +/** V1's partition and strict read lifetime are a local report contract, never an authorization. */ +public final class CoverageDistributionPolicy { + public static final String RULE_VERSION="COVERAGE_DISTRIBUTION_V1"; + public static final List BUCKETS=List.of("DAYS_0_30","DAYS_30_60","DAYS_60_90","DAYS_GT_90","NO_OUTBOUND_30D","NO_AVAILABLE","UNKNOWN"); + private CoverageDistributionPolicy(){} + public static void validate(AnalyticsInventoryOptions o){ + if(o.coverageBucket()!=null&&(!BUCKETS.contains(o.coverageBucket())||!RULE_VERSION.equals(o.bucketRuleVersion())))throw new BizException("库存分布桶或规则版本无效"); + if(o.coverageBucket()==null&&o.bucketRuleVersion()!=null)throw new BizException("分布规则版本必须与分桶条件一起提供"); + if(o.coverageSelection()!=null&&!"FINITE_POSITIVE".equals(o.coverageSelection()))throw new BizException("库存有限天数筛选无效"); + if(o.coverageBucket()!=null&&(o.coverageSelection()!=null||!"ALL".equals(o.attentionValue()))||o.coverageSelection()!=null&&!"ALL".equals(o.attentionValue()))throw new BizException("分桶、有限天数与关注条件互斥"); + if(o.drilldownFrom()!=null&&!"DASHBOARD".equals(o.drilldownFrom()))throw new BizException("库存下钻来源无效"); + } + public static Instant expiresAt(AnalyticsResponses.Context context,Instant freshUntil){ + if(context.inventoryRunId()==null||context.inventoryRunId()==0||!"VALID".equals(context.inventoryStatus())||freshUntil==null)return null; + return freshUntil.isBefore(context.queryExpiresAt())?freshUntil:context.queryExpiresAt(); + } + public static void requireDrilldown(AnalyticsQuery q,AnalyticsInventoryOptions o,AnalyticsResponses.Context context,Instant freshUntil,Instant now){ + if(!"DASHBOARD".equals(o.drilldownFrom()))return; + if(!q.pinned())throw new BizException("经营总览下钻必须携带原完整版本上下文"); + Instant expires=expiresAt(context,freshUntil); + if(expires==null||!now.isBefore(expires))throw new BizException("原图库存已过期,请刷新总览"); + } +} 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 new file mode 100644 index 000000000..986a98174 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +/** Expressions address AnalyticsInventorySql.result. Comparisons use cross products, never display-rounded days. */ +public final class CoverageDistributionSql { + private CoverageDistributionSql(){} + public static String candidate(){return "(physical_quantity IS NULL OR physical_quantity>0)";} + public static String finitePositive(){return "available_quantity>0 AND outbound_quantity30d>0";} + public static String bucket(){return "CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL THEN 'UNKNOWN' " + +"WHEN physical_quantity=0 THEN NULL WHEN available_quantity=0 THEN 'NO_AVAILABLE' " + +"WHEN outbound_quantity30d IS NULL THEN 'UNKNOWN' WHEN outbound_quantity30d=0 THEN 'NO_OUTBOUND_30D' " + +"WHEN available_quantity*30<=outbound_quantity30d*30 THEN 'DAYS_0_30' " + +"WHEN available_quantity*30<=outbound_quantity30d*60 THEN 'DAYS_30_60' " + +"WHEN available_quantity*30<=outbound_quantity30d*90 THEN 'DAYS_60_90' ELSE 'DAYS_GT_90' END";} + public static String unknownReason(){return "CASE WHEN physical_quantity IS NULL THEN 'PHYSICAL_INVENTORY_UNKNOWN' WHEN available_quantity IS NULL THEN 'AVAILABLE_INVENTORY_UNKNOWN' " + +"WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN owner_outbound IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' " + +"WHEN observation_eligible=0 THEN 'OBSERVATION_INSUFFICIENT' ELSE 'METRIC_DEPENDENCY_UNKNOWN' END";} + public static String filter(AnalyticsInventoryOptions o){ + if(o.coverageBucket()!=null)return "#{p.inventoryRun}>0 AND "+candidate()+" AND ("+bucket()+")=#{o.coverageBucket}"; + if(o.coverageSelection()!=null)return finitePositive(); + return ""; + } +} 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 new file mode 100644 index 000000000..c21fd504b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java @@ -0,0 +1,90 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.*; +import java.util.*; + +@Service +@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) +public class DashboardQueryService { + private final AnalyticsQueryService contextService; + private final DashboardReadMapper mapper; + public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper){this.contextService=contextService;this.mapper=mapper;} + public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){ + AnalyticsQuery q=DashboardRequest.normalize(input); + var snapshot=contextService.context(q,true,false,false);var c=snapshot.context(); + var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); + var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom, + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD"); + var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null); + var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive()); + Map stats=normalize(mapper.statistics(p,o));var ranked=mapper.rankings(p,o); + var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days); + var trendSummary=SalesBucketPolicy.aggregate(trendPoints); + var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days);var fixed=SalesBucketPolicy.aggregate(fixedPoints); + var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(), + trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays()); + var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList(); + var interval=new DashboardResponses.Metric(trendSummary.outboundQuantity(),trendSummary.observedQuantity(),"PCS",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),trendSummary.outboundQuantity(),trendSummary.observedQuantity()); + // A partial observed quantity is never divided by 30 and presented as an estimated daily average. + var average=new DashboardResponses.Metric(divide30(fixed.outboundQuantity()),divide30(fixed.outboundQuantity()),"PCS_PER_DAY",fixed.dataStatus(),null,fixed.reasonCodes(),null,null); + boolean inventoryAvailable=base.inventoryRun()>0; + var kpis=new DashboardResponses.Kpis(interval,average,stockMetric(stats,"physical_quantity_known","physical_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c), + stockMetric(stats,"available_quantity_known","available_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c), + stockMetric(stats,"physical_skus","physical_quantity_unknown","SKU",inventoryAvailable,base.stockFresh(),c), + stockMetric(stats,"no_outbound_eligible","no_outbound_unknown","SKU",inventoryAvailable,base.stockFresh(),c)); + Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil); + return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,fixedFrom,fixedUntil,30,freshUntil,drilldownUntil),kpis, + new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c), + ranking("HOT",stats,ranked,base.salesRun()>0,fixedFrom,fixedUntil,c), + ranking("LONG",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c), + ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c)); + } + private static DashboardResponses.Metric stockMetric(Map s,String knownField,String unknownField,String unit,boolean available,boolean fresh,AnalyticsResponses.Context c){ + if(!available)return new DashboardResponses.Metric(null,null,unit,"UNKNOWN",null,List.of("INVENTORY_UNAVAILABLE"),null,null); + long unknown=count(s,unknownField);BigDecimal known=decimal(s,knownField); + if(count(s,"total_skus")==0&&fresh)known=BigDecimal.ZERO; + if(!fresh&&count(s,"total_skus")==0)known=null; + BigDecimal value=fresh&&unknown==0?known:null; + String status=value!=null?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN"; + List reasons=value!=null?List.of():!fresh?List.of("INVENTORY_"+c.inventoryStatus()):List.of("METRIC_DEPENDENCY_UNKNOWN"); + return new DashboardResponses.Metric(value,known,unit,status,unknown,reasons,null,null); + } + private static DashboardResponses.Distribution distribution(Map s,boolean available,AnalyticsResponses.Context c){ + List buckets=new ArrayList<>(); + for(String code:CoverageDistributionPolicy.BUCKETS){String suffix=code.toLowerCase(Locale.ROOT);long n=count(s,"bucket_"+suffix);BigDecimal physical=decimal(s,"physical_"+suffix); + if(n==0&&available)physical=BigDecimal.ZERO; + buckets.add(new DashboardResponses.Bucket(code,available?n:null,available?physical:null,available?count(s,"unknown_"+suffix):null)); + } + Map reasons=new LinkedHashMap<>();s.forEach((key,value)->{if(key.startsWith("reason_")&&value!=null){long n=new BigDecimal(value.toString()).longValueExact();if(n>0)reasons.put(key.substring(7).toUpperCase(Locale.ROOT),n);}}); + String status=!available?"UNKNOWN":count(s,"bucket_unknown")>0?"PARTIAL":"COMPLETE"; + List codes=!available?List.of("INVENTORY_UNAVAILABLE"):count(s,"bucket_unknown")>0?List.of("UNASSESSABLE_SKUS"):List.of(); + if(available&&!"VALID".equals(c.inventoryStatus())){status="PARTIAL";codes=List.of("INVENTORY_"+c.inventoryStatus());} + return new DashboardResponses.Distribution(CoverageDistributionPolicy.RULE_VERSION,available?count(s,"candidate_skus"):null, + available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of()); + } + private static DashboardResponses.Ranking ranking(String kind,Map s,List rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c){ + String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";}; + List selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator + .comparing(DashboardReadMapper.Ranked::mainValue,Comparator.reverseOrder()) + .thenComparing(r->"HOT".equals(kind)?r.orderCount():0L,Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList(); + var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS", + "HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(), + divide30("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d()),r.coverageDays())).toList():List.of(); + long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE"; + List reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of(); + if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS"); + if(available&&"HOT".equals(kind)&&!"VALID".equals(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());} + return new DashboardResponses.Ranking(from,until,30,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons); + } + private static Map normalize(Map source){Map result=new HashMap<>();if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));return result;} + 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 value==null?null:value.divide(BigDecimal.valueOf(30),12,RoundingMode.HALF_UP);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadMapper.java new file mode 100644 index 000000000..30e1ec4af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadMapper.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DashboardReadMapper { + record Day(LocalDate businessDate,BigDecimal observedQuantity,Long invalidRowCount){} + record Ranked(String rankCode,String customerCode,String skuCode,String productName,BigDecimal mainValue, + BigDecimal ownerOutbound,Long orderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity, + BigDecimal frozenQuantity,BigDecimal defectivePhysicalQuantity,BigDecimal outboundQuantity30d,BigDecimal coverageDays){} + @Select("SELECT business_date,SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN outbound_quantity END) observed_quantity," + +"COUNT(CASE WHEN quantity_unit IS NULL OR quantity_unit<>'PCS' OR outbound_quantity IS NULL OR outbound_quantity<0 THEN 1 END) invalid_row_count " + +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND source_account=#{p.account} AND warehouse_code=#{p.warehouse} " + +"AND ((business_date>=#{chartFrom} AND business_date<#{chartUntil}) OR (business_date>=#{p.from} AND business_date<#{p.until})) GROUP BY business_date ORDER BY business_date") + List daily(@Param("p") AnalyticsSqlRequest request,@Param("chartFrom") LocalDate chartFrom,@Param("chartUntil") LocalDate chartUntil); + @SelectProvider(type=DashboardReadSql.class,method="statistics") + Map statistics(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=DashboardReadSql.class,method="rankings") + List rankings(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); +} 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 new file mode 100644 index 000000000..ecd198ae4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.List; +import java.util.Map; + +/** Three bounded aggregate queries: warehouse daily points, one statistics row, at most 30 ranked rows. */ +public final class DashboardReadSql { + private DashboardReadSql(){} + public static String statistics(Map args){ + var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(AnalyticsInventorySql.cte(p)); + sql.append(", classified AS (SELECT r.*,CASE WHEN ").append(CoverageDistributionSql.candidate()).append(" THEN ") + .append(CoverageDistributionSql.bucket()).append(" END bucket_code,").append(CoverageDistributionSql.unknownReason()).append(" unknown_reason FROM result r) SELECT COUNT(*) total_skus"); + for(String field:List.of("physical_quantity","available_quantity"))sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); + sql.append(",COUNT(CASE WHEN physical_quantity>0 THEN 1 END) physical_skus,COUNT(CASE WHEN physical_quantity=0 THEN 1 END) excluded_zero_skus") + .append(",COUNT(CASE WHEN bucket_code IS NOT NULL THEN 1 END) candidate_skus") + .append(",COUNT(CASE WHEN owner_outbound>0 THEN 1 END) hot_eligible,COUNT(CASE WHEN owner_outbound IS NULL THEN 1 END) hot_unknown") + .append(",COUNT(CASE WHEN ").append(CoverageDistributionSql.finitePositive()).append(" THEN 1 END) long_eligible") + .append(",COUNT(CASE WHEN available_quantity IS NULL OR (available_quantity>0 AND outbound_quantity30d IS NULL) THEN 1 END) long_unknown") + .append(",COUNT(CASE WHEN no_outbound=1 THEN 1 END) no_outbound_eligible,COUNT(CASE WHEN no_outbound IS NULL THEN 1 END) no_outbound_unknown"); + for(String code:CoverageDistributionPolicy.BUCKETS){String alias=code.toLowerCase(java.util.Locale.ROOT); + sql.append(",COUNT(CASE WHEN bucket_code='").append(code).append("' THEN 1 END) bucket_").append(alias) + .append(",SUM(CASE WHEN bucket_code='").append(code).append("' THEN physical_quantity END) physical_").append(alias) + .append(",COUNT(CASE WHEN bucket_code='").append(code).append("' AND physical_quantity IS NULL THEN 1 END) unknown_").append(alias); + } + for(String reason:List.of("PHYSICAL_INVENTORY_UNKNOWN","AVAILABLE_INVENTORY_UNKNOWN","UNIT_UNKNOWN_OR_CONFLICT","SALES_WINDOW_UNAVAILABLE","OBSERVATION_INSUFFICIENT","METRIC_DEPENDENCY_UNKNOWN")) + sql.append(",COUNT(CASE WHEN bucket_code='UNKNOWN' AND unknown_reason='").append(reason).append("' THEN 1 END) reason_").append(reason.toLowerCase(java.util.Locale.ROOT)); + return AnalyticsInventorySql.typed(sql+" FROM classified"); + } + public static String rankings(Map args){var p=(AnalyticsSqlRequest)args.get("p"); + String sql=AnalyticsInventorySql.cte(p) + +", hot_rank AS (SELECT * FROM result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)" + +", long_rank AS (SELECT * FROM result WHERE "+CoverageDistributionSql.finitePositive()+" ORDER BY coverage_days DESC,sku_code ASC,customer_code ASC LIMIT 10)" + +", no_rank AS (SELECT * FROM result WHERE no_outbound=1 ORDER BY physical_quantity DESC,sku_code ASC,customer_code ASC LIMIT 10) "; + sql+=select("HOT","owner_outbound","hot_rank")+" UNION ALL "+select("LONG","coverage_days","long_rank")+" UNION ALL "+select("NO_OUTBOUND","physical_quantity","no_rank"); + return AnalyticsInventorySql.typed(sql); + } + private static String select(String kind,String value,String table){return "SELECT '"+kind+"' rank_code,customer_code,sku_code,product_name,"+value+" main_value,owner_outbound,order_count,physical_quantity,available_quantity,frozen_quantity,defective_physical_quantity,outbound_quantity30d,coverage_days FROM "+table;} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardRequest.java new file mode 100644 index 000000000..b5c776eb8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardRequest.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.util.Set; + +public final class DashboardRequest { + private DashboardRequest(){} + public static void validateParameterNames(Set names){ + if(!Set.of("sourceAccount","warehouseCode","rangePreset","startDate","endDate","bucket","salesRunId","inventoryRunId","salesScopeHash","inventoryScopeHash","queryAsOf").containsAll(names)) + throw new BizException("经营总览不接受货主、SKU、分页、任意榜单数量或其他筛选条件"); + } + public static AnalyticsQuery normalize(AnalyticsQuery q){ + if(q.customerCode()!=null||q.sku()!=null||q.current()!=null||q.size()!=null||q.windowDays()!=null)throw new BizException("经营总览仅支持仓库、趋势范围与固定版本上下文"); + return new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,q.rangePreset()==null?"LAST_15_DAYS":q.rangePreset(),q.startDate(),q.endDate(),q.bucket(),null,null,null, + q.salesRunId(),q.inventoryRunId(),q.salesScopeHash(),q.inventoryScopeHash(),q.queryAsOf()); + } +} 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 new file mode 100644 index 000000000..114635903 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public final class DashboardResponses { + private DashboardResponses(){} + public record Context(@JsonUnwrapped AnalyticsResponses.Context versionContext,LocalDate fixedWindowStartDate, + LocalDate fixedWindowEndDateExclusive,int fixedWindowDays,Instant inventoryFreshUntil,Instant inventoryDrilldownExpiresAt){} + public record Metric(BigDecimal value,BigDecimal knownSubtotal,String unit,String dataStatus,Long unknownSkuCount, + List reasonCodes,BigDecimal outboundQuantity,BigDecimal observedQuantity){} + public record Kpis(Metric intervalOutboundQuantity,Metric averageDailyOutboundQuantity30d,Metric physicalQuantity, + Metric availableQuantity,Metric physicalSkuCount,Metric noOutboundSkuCount30d){} + public record SalesTrend(AnalyticsResponses.SalesSummary summary,List points){} + public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){} + public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount, + List buckets,String dataStatus,List reasonCodes,Map unknownReasons){} + public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal outboundQuantity30d, + Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity, + BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays){} + public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount, + Long unassessableCount,List items,String dataStatus,List reasonCodes){} + public record Dashboard(Context context,Kpis kpis,SalesTrend salesTrend,Distribution coverageDistribution, + Ranking hotSkus,Ranking longCoverageSkus,Ranking noOutboundSkus){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java new file mode 100644 index 000000000..2eb1fbff9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +/** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */ +public final class DashboardSalesPolicy { + private DashboardSalesPolicy(){} + public static List points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List rows){ + Map days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row); + List result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai"); + for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){ + var actual=days.get(day);Instant start=day.atStartOfDay(zone).toInstant(),end=day.plusDays(1).atStartOfDay(zone).toInstant(); + Instant lower=c.salesCoveredFrom()==null?null:(c.salesCoveredFrom().isAfter(start)?c.salesCoveredFrom():start); + Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end); + boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper); + boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0; + boolean trusted="VALID".equals(c.salesStatus())&&!c.rebuildRequired(); + boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&c.completeDayFrom()!=null&&c.completeDayUntil()!=null + &&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end); + BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null; + if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO; + String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List reasons=new ArrayList<>(); + if(!sourceKnown)reasons.add("SALES_NOT_COVERED");if(!trusted)reasons.add("SALES_"+c.salesStatus()); + if(invalid)reasons.add("SALES_QUANTITY_OR_UNIT_INVALID");if(!complete)reasons.add(day.equals(c.anchorDate())?"TODAY_INCOMPLETE":"SALES_DAY_INCOMPLETE"); + if(known==null)reasons.add("SALES_NOT_COVERED"); + result.add(new SalesBucketPolicy.Point(day,complete?known:null,known,null,status,List.copyOf(new LinkedHashSet<>(reasons)),intersects?lower:null,intersects?upper:null)); + } + return List.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java index 6c835a3ae..2b5cdd9d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -84,7 +84,7 @@ public class SelfopDiscoveryService { }); } } - route(task,manifest,lease); + route(task,manifest,lease,false); for(var entry:manifest.getObjects().entrySet()) { var ref=entry.getValue(); if(ref.getRefreshTaskId()!=null){ @@ -157,8 +157,11 @@ public class SelfopDiscoveryService { SelfopProgressProjection.freezeRun(finalFetchTask,finalFetchRun);finalFetchRun.setStatus("ROUTED");finalFetchRun.setFetchFinishedAtUtc(utcNow());finalFetchRun.setFinishedAtUtc(utcNow());finalFetchRun.setUpdatedAt(utcNow());runs.updateById(finalFetchRun); finalFetchTask.setStatus("SUCCEEDED");finalFetchTask.setUpdatedAt(utcNow());tasks.updateById(finalFetchTask);} }); - route(task,manifest,lease); + route(task,manifest,lease,false); } + // A directory warehouse is provisional until every account detail (including resumed + // revision checks) has resolved. An empty warehouse must not publish a false zero early. + route(task,manifest,lease,true); tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow(); SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run); run.setStatus("ROUTED");run.setCoverageStatus("COMPLETE");run.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2); @@ -202,11 +205,12 @@ public class SelfopDiscoveryService { manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1), manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);} tasks.updateById(task);} - private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease){ + private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease,boolean detailsResolved){ // Refresh commands only replace a parent manifest reference; never create an independent // fact writer that could publish around that parent's half-applied barrier. if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return; tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return; + boolean accountDetailsReady=detailsResolved&&manifest.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null); for(var scope:scopes.selectList(new QueryWrapper().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){ if(!config.getWarehouses().containsKey(scope.getWarehouseCode()))continue; var own=new SelfopRoutingManifest();own.setDirectoryComplete(true);own.setTotal(0L);own.setPages(manifest.getPages()); @@ -220,8 +224,8 @@ public class SelfopDiscoveryService { } } own.setTotal((long)own.getObjects().size()); - boolean ready=scope.getSourceTimezone()!=null&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())) - &&own.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null); + boolean ready=accountDetailsReady&&scope.getSourceTimezone()!=null + &&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())); var child=new SelfopSyncTask();child.setSourceAccount(parent.getSourceAccount());child.setTaskRole("WAREHOUSE_SYNC");child.setScopeId(scope.getId());child.setWarehouseCode(scope.getWarehouseCode()); child.setExecutionPurpose(parent.getExecutionPurpose()); child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline()); 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 5d5a08997..bbd170bb3 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 @@ -21,6 +21,39 @@ info: 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 security: [{bearerAuth: []}] paths: + /api/platform/v1/last-leg/self-operated-analytics/dashboard: + get: + summary: 单仓固定版本经营总览 + description: >- + 平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。 + 默认LAST_15_DAYS仅改变趋势及区间出库,日均、互斥分桶和三个Top10固定至昨日30完整日。 + 不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。 + 库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。 + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} + - {$ref: '#/components/parameters/bucket'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + responses: + '200': + description: 统一ApiResponse;版本或参数错误为业务失败,不降级成空图。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Dashboard'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} /api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary: get: summary: SKU五周期或历史累计清单 @@ -149,6 +182,10 @@ paths: summary: 正品库存与固定30日可售天数 description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' parameters: + - {$ref: '#/components/parameters/coverageBucket'} + - {$ref: '#/components/parameters/bucketRuleVersion'} + - {$ref: '#/components/parameters/coverageSelection'} + - {$ref: '#/components/parameters/drilldownFrom'} - {$ref: '#/components/parameters/attention'} - {$ref: '#/components/parameters/sortBy'} - {$ref: '#/components/parameters/coverageDaysThreshold'} @@ -338,6 +375,14 @@ components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} parameters: + coverageBucket: + 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]} + 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和货主升序} @@ -370,6 +415,99 @@ components: customerCodeRequired: {name: customerCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} skuRequired: {name: sku, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} schemas: + DashboardContext: + allOf: + - {$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: + type: object + properties: + value: {type: number, nullable: true} + knownSubtotal: {type: number, nullable: true} + unit: {type: string, enum: [PCS, PCS_PER_DAY, SKU]} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + unknownSkuCount: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + outboundQuantity: {type: number, nullable: true} + observedQuantity: {type: number, nullable: true} + DashboardKpis: + type: object + properties: + intervalOutboundQuantity: {$ref: '#/components/schemas/DashboardMetric'} + averageDailyOutboundQuantity30d: {$ref: '#/components/schemas/DashboardMetric'} + physicalQuantity: {$ref: '#/components/schemas/DashboardMetric'} + availableQuantity: {$ref: '#/components/schemas/DashboardMetric'} + physicalSkuCount: {$ref: '#/components/schemas/DashboardMetric'} + noOutboundSkuCount30d: {$ref: '#/components/schemas/DashboardMetric'} + CoverageDistribution: + type: object + description: 数值桶按未舍入交叉乘积判断;七桶互斥守恒。库存版未发布时计数为空,不把销量候选伪造成库存总量。 + properties: + bucketRuleVersion: {type: string, enum: [COVERAGE_DISTRIBUTION_V1]} + candidateSkuCount: {type: string, nullable: true} + excludedZeroPhysicalSkuCount: {type: string, nullable: true} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + reasonCodes: {type: array, items: {type: string}} + unknownReasons: {type: object, additionalProperties: {type: string}} + buckets: + type: array + minItems: 7 + maxItems: 7 + 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]} + skuCount: {type: string, nullable: true} + knownPhysicalQuantity: {type: number, nullable: true} + unknownPhysicalSkuCount: {type: string, nullable: true} + DashboardRankingItem: + type: object + properties: + customerCode: {type: string} + sku: {type: string} + productName: {type: string, nullable: true} + value: {type: number} + unit: {type: string, enum: [PCS, DAY]} + outboundQuantity30d: {type: number, nullable: true} + skuOutboundOrderCount: {type: string, nullable: true} + physicalQuantity: {type: number, nullable: true} + availableQuantity: {type: number, nullable: true} + frozenQuantity: {type: number, nullable: true} + defectivePhysicalQuantity: {type: number, nullable: true} + averageDailyOutboundQuantity: {type: number, nullable: true} + inventoryCoverageDays: {type: number, nullable: true} + DashboardRanking: + type: object + description: eligibleCount来自完整候选;items最多10项,不用Top100数量冒充全量合格数。 + properties: + windowStartDate: {type: string, format: date} + windowEndDateExclusive: {type: string, format: date} + calculationWindowDays: {type: integer, enum: [30]} + eligibleCount: {type: string, nullable: true} + unassessableCount: {type: string, nullable: true} + items: {type: array, maxItems: 10, items: {$ref: '#/components/schemas/DashboardRankingItem'}} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + reasonCodes: {type: array, items: {type: string}} + Dashboard: + type: object + properties: + context: {$ref: '#/components/schemas/DashboardContext'} + kpis: {$ref: '#/components/schemas/DashboardKpis'} + salesTrend: + type: object + properties: + summary: {$ref: '#/components/schemas/SalesSummary'} + points: {type: array, maxItems: 366, items: {$ref: '#/components/schemas/Point'}} + coverageDistribution: {$ref: '#/components/schemas/CoverageDistribution'} + hotSkus: {$ref: '#/components/schemas/DashboardRanking'} + longCoverageSkus: {$ref: '#/components/schemas/DashboardRanking'} + noOutboundSkus: {$ref: '#/components/schemas/DashboardRanking'} OwnerOption: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java index fee983548..818bbe37b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java @@ -23,7 +23,7 @@ class PlatformAnalyticsControllerTest { private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));} @Test void rejectsCustomerEvenWithReadPermission(){ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); - var controller=new PlatformAnalyticsController(service,collection); + var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class)); var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null)); assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null)); @@ -31,7 +31,7 @@ class PlatformAnalyticsControllerTest { } @Test void writePermissionAloneDoesNotAuthorizeAnyRead(){ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); - var controller=new PlatformAnalyticsController(service,collection); + var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class)); var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null)); assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null)); @@ -40,7 +40,7 @@ class PlatformAnalyticsControllerTest { } @Test void allElevenReadEndpointsShareTheIndependentGuard(){ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); - var c=new PlatformAnalyticsController(service,collection);var user=platform(); + var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform(); c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null); c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null); verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null); @@ -56,7 +56,7 @@ class PlatformAnalyticsControllerTest { } @Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); - var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build(); mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary") .param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","CUSTOM").param("startDate","2026-10-01") .param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22") @@ -68,7 +68,7 @@ class PlatformAnalyticsControllerTest { } @Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); - var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build(); mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO") .param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk()); var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture()); @@ -76,7 +76,7 @@ class PlatformAnalyticsControllerTest { } @Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{ var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); - var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build(); mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W") .param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk()); var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class); @@ -84,4 +84,14 @@ class PlatformAnalyticsControllerTest { assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold()); verifyNoInteractions(collection); } + @Test void dashboardUsesIndependentReadGuardAndRejectsArbitraryTopN()throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);var dashboard=mock(DashboardQueryService.class); + var controller=new PlatformAnalyticsController(service,collection,dashboard); + var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); + assertThrows(ResponseStatusException.class,()->controller.dashboard(customer,null,java.util.Map.of()));verifyNoInteractions(dashboard); + assertThrows(com.baiyue.common.core.exception.BizException.class,()->controller.dashboard(platform(),null,java.util.Map.of("topN","500"))); + var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/dashboard").param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","TODAY")).andExpect(status().isOk()); + var q=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(dashboard).dashboard(q.capture());assertEquals("TODAY",q.getValue().rangePreset());assertNull(q.getValue().size()); + } } 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 5dbdf443d..5fd51114e 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 @@ -247,14 +247,24 @@ class AnalyticsQueryServiceTest { } @Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){ var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null); - assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null))); + assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null,null,null,null,null))); assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options())); - assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null))); - assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1))); - assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null,null,null,null,null))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1,null,null,null,null))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null))); verifyNoInteractions(mapper); } - private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null);} + @Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){ + var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD"); + 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); + 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()); + } + private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);} private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){ BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales); return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java index c8f3c33d4..08880d127 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java @@ -77,7 +77,8 @@ class AnalyticsReadMapperTest { assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT")); @@ -90,10 +91,11 @@ class AnalyticsReadMapperTest { assertTrue(stats.contains("LEAST(SUM(CASE WHEN eligible=1 THEN amount ELSE 0 END) OVER (),100) total")); assertTrue(stats.contains("excluded_count"));assertFalse(stats.contains(" LIMIT ")); assertFalse(stats.contains("ranged AS"));assertFalse(stats.contains("TIMESTAMPDIFF")); + if("HOT".equals(kind))assertFalse(stats.contains("ll_selfop_inventory_snapshot")); } } @Test void inventoryHistoryOnlyEnrichesQualifiedPageAndNeverCountsOrOwnerTotals(){ - var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90); + var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,null,null); String page=bound(c,"inventoryPage",Map.of("p",p,"o",o)).getSql(); assertTrue(page.contains("paged AS (SELECT * FROM result WHERE above_threshold=1 ORDER BY")); assertTrue(page.indexOf("LIMIT ? OFFSET ?")0")); + } + } @Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){ String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql(); assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL")); 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 3b4ea65e9..ef5843649 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 @@ -94,7 +94,9 @@ public class AnalyticsSqlSemanticTest { assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0)); var row=rows.get(0); assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount()); - assertTrue(page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)).isEmpty()); + var hot=page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)); + assertEquals(2,hot.size());assertTrue(hot.stream().allMatch(r->r.windowQuantity().compareTo(new BigDecimal("5"))==0)); + assertTrue(hot.stream().allMatch(r->"UNKNOWN".equals(r.inventoryQuality()))); } @Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception { profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); @@ -106,6 +108,30 @@ public class AnalyticsSqlSemanticTest { assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d()); assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount()); } + @Test void hotCompleteWindowIgnoresTodayUnitButTodayValueRemainsUnknown() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(1),"12");daily(11,"W","C","SKU",D,"500"); + try(var sql=schema.createStatement()){sql.executeUpdate("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='BOX' WHERE business_date=DATE '2026-10-12'");} + var rows=page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)); + assertEquals(1,rows.size());assertDecimal("12",rows.get(0).windowQuantity()); + assertDecimal("12",rows.get(0).sales30Quantity());assertNull(rows.get(0).todayQuantity()); + var stats=rankingStats(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)); + assertEquals(1L,stats.get(0).total()); + } + @Test void stockOnlyCountMatchesPageForFrozenAndDefectiveWithoutSalesDependency() throws Exception { + profile(22,"C","VALID","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","BAD-UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX"); + profile(22,"C","MISSING","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("VALID",0,"5","1","4","0","VALID",null);stockExact("VALID",1,"8","0","8","0","VALID",null); + stockExact("BAD-UNIT",1,"8","0","8","0","VALID",null); + stockExact("MISSING",1,null,"0",null,"0","UNKNOWN","FIELD_MISSING"); + var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20); + for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){ + var o=new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null); + assertEquals(1L,((Number)literalQuery("inventoryCount",p,o).get(0)).longValue()); + assertEquals(List.of("VALID"),inventory(attention,null,null,0,20).stream().map(Inventory->Inventory.skuCode()).toList()); + } + } @Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception { profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING"); @@ -132,7 +158,7 @@ public class AnalyticsSqlSemanticTest { var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size()); assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays()); var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20); - var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300); + var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300,null,null,null,null); assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue()); Map totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue()); assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known")); @@ -141,7 +167,7 @@ public class AnalyticsSqlSemanticTest { profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null); daily(11,"W","C","NEW",D.minusDays(1),"5"); var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound()); - Map totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30)); + Map totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null)); assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical")); } @@ -150,17 +176,17 @@ public class AnalyticsSqlSemanticTest { profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); } var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20); - assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null))); - assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null))); - assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null))); - assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null)).get(0)).longValue()); + assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null,null,null,null,null))); + assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null,null,null,null,null))); + assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null,null,null,null,null))); + assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)).get(0)).longValue()); } @Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception { profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX"); daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null); var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity()); - var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30)); + var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null)); assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); } @Test void yearlySparseSummaryPreservesOwnerIdentityAndCompletePartialUnknownSpanCounts() throws Exception { @@ -196,9 +222,13 @@ public class AnalyticsSqlSemanticTest { profile(22,"C","ZERO","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");stock("C","ZERO",0,"0"); profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stock("C","NEW",0,"10"); profile(22,"C","UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),null);stock("C","UNIT",0,"10"); + // Stock's missing unit must not create a sales-unit exclusion: the complete + // sales window still proves no outbound for UNIT. A broken sales profile is different. + profile(11,"C","SALES-UNIT","VALID_OUTBOUND",utc(D.minusDays(40),0),null); + daily(11,"W","C","SALES-UNIT",D.minusDays(1),"1000"); var hot=request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20); var hotStats=rankingStats(hot);assertTrue(hotStats.stream().allMatch(s->s.total()==100L)); - assertEquals(2L,hotStats.stream().filter(s->"ZERO_OUTBOUND".equals(s.reason())).findFirst().orElseThrow().excludedCount()); + assertEquals(3L,hotStats.stream().filter(s->"ZERO_OUTBOUND".equals(s.reason())).findFirst().orElseThrow().excludedCount()); assertEquals(1L,hotStats.stream().filter(s->"UNIT_UNKNOWN_OR_CONFLICT".equals(s.reason())).findFirst().orElseThrow().excludedCount()); assertEquals("B",page(hot).get(0).customerCode()); var slowStats=rankingStats(request("SLOW",D.minusDays(30),D,null,null,utc(D,8),0,20)); @@ -218,12 +248,12 @@ public class AnalyticsSqlSemanticTest { assertEquals(D.minusDays(60),second.get(0).lastOutbound()); } @SuppressWarnings("unchecked") private List rankingStats(AnalyticsSqlRequest p){ - return (List)(List)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null)); + return (List)(List)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)); } private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));} @SuppressWarnings("unchecked") private List inventory(String attention,Integer threshold,String sort,long offset,int size){ - return (List)(List)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold)); + return (List)(List)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold,null,null,null,null)); } @SuppressWarnings("unchecked") private Map totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ 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 new file mode 100644 index 000000000..04f2cf8aa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +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());} + @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);var summary=SalesBucketPolicy.aggregate(points); + assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus()); + } + @Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){ + var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of()); + assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(BigDecimal.ZERO,points.get(0).outboundQuantity()); + assertEquals("PARTIAL",points.get(1).dataStatus());assertEquals(BigDecimal.ZERO,points.get(1).observedQuantity());assertNull(points.get(1).outboundQuantity()); + var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity()); + } + @Test void strictInventoryDrilldownExpiresBeforeContextWhileOrdinaryInventoryRemainsAllowed(){ + var c=context(NOW.minusSeconds(86400),NOW,true);var q=query(true);var strict=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,"DASHBOARD"); + Instant fresh=NOW.plusSeconds(60);assertEquals(fresh,CoverageDistributionPolicy.expiresAt(c,fresh)); + assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh.minusSeconds(1))); + assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh)).getMessage()); + assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(query(false),strict,c,fresh,NOW)); + assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null),c,fresh,fresh.plusSeconds(1))); + assertNull(CoverageDistributionPolicy.expiresAt(context(null,null,false),fresh)); + } + @Test void bucketSelectionAndAttentionCannotSilentlyBroadenToWarehouse(){ + for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null), + new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null), + new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null), + new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null), + new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",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()); + } + private static AnalyticsQuery query(boolean pinned){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,null,null,null,pinned?11L:null,pinned?22L:null,pinned?"a".repeat(64):null,pinned?"b".repeat(64):null,pinned?NOW:null);} +} 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 new file mode 100644 index 000000000..b0ddd192a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesRangePolicy; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DashboardQueryServiceTest { + @Test void oneContextKeepsThirtyDaySalesAndHotAvailableWhenInventoryUnavailable()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); + var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false); + var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366); + var snapshot=new AnalyticsQueryService.Snapshot(q,null,null,true,true,day.minusDays(30),day,range,context); + when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot); + var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD"); + when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p); + when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L))); + when(mapper.statistics(any(),any())).thenReturn(Map.of("total_skus",200L,"hot_eligible",200L,"hot_unknown",0L)); + when(mapper.rankings(any(),any())).thenReturn(List.of(new DashboardReadMapper.Ranked("HOT","A","X","Product",new BigDecimal("30"),new BigDecimal("30"),1L,null,null,null,null,null,null))); + var result=service.dashboard(q); + assertEquals(0,BigDecimal.ONE.compareTo(result.kpis().averageDailyOutboundQuantity30d().value())); + 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()); + verify(mapper).daily(any(),any(),any());verify(mapper).statistics(any(),any());verify(mapper).rankings(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()); + } +} 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 new file mode 100644 index 000000000..187e8d624 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -0,0 +1,88 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.*; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.*; +import java.math.BigDecimal; +import java.sql.*; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Production SQL on process-local synthetic H2, never a real environment or outbound HTTP. */ +class DashboardSqlSemanticTest { + private Connection connection;private SqlSession session;private Configuration config;private int sequence; + private static final LocalDate D=LocalDate.of(2026,10,13); + @BeforeEach void setup()throws Exception{ + var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:dashboard_"+UUID.randomUUID().toString().replace("-","")+";MODE=MySQL","sa","");connection=ds.getConnection(); + try(var s=connection.createStatement()){ + s.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'"); + s.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); + s.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); + s.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))"); + } + config=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DashboardReadMapper.class);config.addMapper(AnalyticsReadMapper.class); + session=new SqlSessionFactoryBuilder().build(config).openSession(); + } + @AfterEach void close()throws Exception{if(session!=null)session.close();if(connection!=null)connection.close();} + @Test void sevenExclusiveBucketsConserveCandidatesAndEveryInventoryDrilldownMatches()throws Exception{ + fixture("NOOUT","100","100",null);fixture("FROZEN","50","0",null);fixture("UNKNOWN",null,null,null); + fixture("THIRTY","30","30","30");fixture("THIRTY04","30.04","30.04","30");fixture("ZERO","0","0",null); + var stats=stats();assertEquals(5,count(stats,"candidate_skus"));assertEquals(1,count(stats,"excluded_zero_skus")); + assertEquals(1,count(stats,"bucket_days_0_30"));assertEquals(1,count(stats,"bucket_days_30_60"));assertEquals(1,count(stats,"bucket_no_outbound_30d")); + assertEquals(1,count(stats,"bucket_no_available"));assertEquals(1,count(stats,"bucket_unknown")); + long sum=0;for(String bucket:CoverageDistributionPolicy.BUCKETS){long n=count(stats,"bucket_"+bucket.toLowerCase(Locale.ROOT));sum+=n; + var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null); + assertEquals(n,((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",o)).get(0)).longValue(),bucket); + }assertEquals(5,sum); + var finite=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null); + assertEquals(count(stats,"long_eligible"),((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",finite)).get(0)).longValue()); + assertEquals(2,count(stats,"no_outbound_eligible")); + var ranked=ranks();assertEquals("THIRTY04",ranked.stream().filter(r->r.rankCode().equals("LONG")).max(Comparator.comparing(DashboardReadMapper.Ranked::mainValue)).orElseThrow().skuCode()); + assertTrue(ranked.stream().anyMatch(r->r.rankCode().equals("NO_OUTBOUND")&&r.skuCode().equals("FROZEN"))); + } + @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")); + 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{ + for(int i=0;i<200;i++){ + String sku=String.format("SKU-%03d",i);profile(11,"C",sku,"PCS");if(i%3!=0)profile(22,"C",sku,i%3==1?"BOX":null); + daily("C",sku,D.minusDays(1),Integer.toString(i+1),"PCS"); + } + daily("C","SKU-199",D,"999","BOX"); + assertEquals(200,count(stats(),"hot_eligible"));var top=ranks().stream().filter(r->r.rankCode().equals("HOT")).sorted(Comparator.comparing(DashboardReadMapper.Ranked::mainValue).reversed()).toList();assertEquals(10,top.size()); + var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT"); + List page=query(AnalyticsReadMapper.class,"page",Map.of("p",hot));assertEquals(10,page.size()); + for(int i=0;i<10;i++){var item=(AnalyticsReadRows.Aggregate)page.get(i);assertEquals(top.get(i).skuCode(),item.skuCode());assertEquals(0,top.get(i).mainValue().compareTo(item.windowQuantity()));} + } + @Test void warehouseDailyIncludesBothOwnersWithoutSkuObservationAndNoQualityMultiplication()throws Exception{ + profile(11,"A","SAME","PCS");profile(11,"B","SAME","PCS");daily("A","SAME",D.minusDays(1),"2","PCS");daily("B","SAME",D.minusDays(1),"5","PCS"); + var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(2),"chartUntil",D.plusDays(1))); + assertEquals(1,rows.size());var row=(DashboardReadMapper.Day)rows.get(0);assertEquals(0,new BigDecimal("7").compareTo(row.observedQuantity()));assertEquals(0L,row.invalidRowCount()); + } + private void fixture(String sku,String physical,String available,String sold)throws Exception{profile(11,"C",sku,"PCS");profile(22,"C",sku,"PCS");stock(sku,physical,available);if(sold!=null)daily("C",sku,D.minusDays(1),sold,"PCS");} + private void profile(long run,String owner,String sku,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){ + p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,sku);p.setString(7,unit);p.setString(8,run==11?"VALID_OUTBOUND":"INVENTORY_OBSERVATION");p.setLong(9,run);p.setObject(10,D.minusDays(40).atStartOfDay().minusHours(8));p.executeUpdate();}} + private void daily(String owner,String sku,LocalDate day,String value,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){ + p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}} + private void stock(String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ + p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}} + private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");} + private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);} + @SuppressWarnings("unchecked") private Map stats(){Map result=new HashMap<>();((Map)query(DashboardReadMapper.class,"statistics",Map.of("p",request(),"o",options())).get(0)).forEach((k,v)->result.put(k.toLowerCase(Locale.ROOT),v));return result;} + @SuppressWarnings("unchecked") private List ranks(){return (List)(List)query(DashboardReadMapper.class,"rankings",Map.of("p",request(),"o",options()));} + private static long count(Map stats,String key){return ((Number)stats.get(key)).longValue();} + private List query(Class mapper,String method,Map args){ + var original=config.getMappedStatement(mapper.getName()+"."+method);var bound=original.getBoundSql(args);var meta=org.apache.ibatis.reflection.SystemMetaObject.forObject(args); + StringBuilder sql=new StringBuilder();int index=0;boolean quoted=false; + for(int i=0;i "VALID".equals(s.getValidationStatus()))); assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount()); } + @Test void emptyWarehouseWaitsForAllAccountDetailsAcrossBudgetYieldAndReceivesRelocatedOrder() { + var destination=new SelfopSyncScope();destination.setId(11L);destination.setSourceAccount("demo");destination.setWarehouseCode("B"); + destination.setDataFamily("OUTBOUND");destination.setScopeHash("warehouse-b-hash");destination.setScopeJson("{}");destination.setSourceTimezone("UTC"); + config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse());when(scopes.selectList(any())).thenReturn(List.of(scope,destination)); + directoryTwoOrders();account.setRequestBudget(3); // Two stable directory scans and only the first detail. + var previous=new SelfopOutboundOrder();previous.setWarehouseCode("A"); + when(orders.selectOne(any())).thenAnswer(inv->{QueryWrapper query=inv.getArgument(0);query.getSqlSegment(); + return query.getParamNameValuePairs().values().contains("two")?previous:null;}); + when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"))).thenReturn(new SelfopPayloads.Order("demo","customer","B","two",null, + 100,null,"2026-10-11 12:00:00",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS")))); + List readiness=new ArrayList<>();doAnswer(inv->{SelfopSyncTask child=inv.getArgument(0);if("WAREHOUSE_SYNC".equals(child.getTaskRole()))readiness.add(child.getWarehouseCode()+":"+child.getStatus());return 1;}).when(tasks).updateById(any(SelfopSyncTask.class)); + + service.collect(task,run,lease); + var warehouseA=children.get("route:1:A");var warehouseB=children.get("route:1:B"); + assertEquals("WAITING_SOURCE",warehouseA.getStatus());assertEquals("WAITING_SOURCE",warehouseB.getStatus()); + assertTrue(read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().isEmpty()); + var partial=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + assertTrue(partial.isDirectoryComplete());assertEquals(1,partial.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count()); + assertTrue(readiness.stream().noneMatch(value->value.endsWith(":READY")));verify(taskService).yield(1L,20L,lease); + verify(client,never()).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two")); + + account.setRequestBudget(10);service.collect(task,run,lease); + assertEquals("READY",warehouseA.getStatus());assertEquals("READY",warehouseB.getStatus()); + var destinationManifest=read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class); + String movedKey=hash(List.of("demo","customer","two"));assertEquals(Set.of(movedKey),destinationManifest.getObjects().keySet()); + var moved=destinationManifest.getObjects().get(movedKey);assertNotNull(moved.getEffectiveStageId());assertEquals("B",moved.getWarehouseCode()); + assertEquals("B",read(stored.get(moved.getEffectiveStageId()).getPayloadJson(),SelfopPayloads.Order.class).warehouseCode()); + assertTrue(read(warehouseA.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().containsKey(movedKey),"Original warehouse must retain the known-order correction reference"); + assertEquals("ROUTED",run.getStatus());verify(client,times(2)).discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2)); + verify(client,times(1)).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("one")); + verify(client,times(1)).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two")); + } @Test void eachActualDetailGetsIndependentObservationNotTaskSequence() { directoryTwoOrders(); service.collect(task, run, lease); assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList()));