feat(selfop): add warehouse dashboard and exact inventory drilldowns

This commit is contained in:
2026-10-13 00:45:39 +08:00
parent a84a9b730e
commit a41c271a67
27 changed files with 827 additions and 48 deletions
+1 -1
View File
@@ -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失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 内部拼车需求池的当前结构在 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。
## 唯一入口与区域边界 ## 唯一入口与区域边界
@@ -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') 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; AND r.status='ACTIVE' AND r.deleted=0;
-- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。 -- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam; USE baiyue_iam;
SET NAMES utf8mb4; 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 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), (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), (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), (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), (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,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)), (4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3)); (4000001,5118130,0,CURRENT_TIMESTAMP(3));
+1 -1
View File
@@ -17,7 +17,7 @@
| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 商户成员、登录手机号与个人资料 | `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` | | 平台用户与海外 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` | | 角色/权限/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` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` |
| 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 持久化安全版本 | `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/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` |
@@ -1,8 +1,9 @@
-- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。 -- 自营业务菜单资源用于菜单管理;四个页面和所有只读接口统一由 list 授权,采集写权限独立。
USE baiyue_iam; USE baiyue_iam;
SET NAMES utf8mb4; 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 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), (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), (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), (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), (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,5118000,0,CURRENT_TIMESTAMP(3)),
(4000001,5118001,0,CURRENT_TIMESTAMP(3)), (4000001,5118001,0,CURRENT_TIMESTAMP(3)),
(4000001,5118100,0,CURRENT_TIMESTAMP(3)), (4000001,5118100,0,CURRENT_TIMESTAMP(3)),
(4000001,5118140,0,CURRENT_TIMESTAMP(3)),
(4000001,5118110,0,CURRENT_TIMESTAMP(3)), (4000001,5118110,0,CURRENT_TIMESTAMP(3)),
(4000001,5118120,0,CURRENT_TIMESTAMP(3)), (4000001,5118120,0,CURRENT_TIMESTAMP(3)),
(4000001,5118130,0,CURRENT_TIMESTAMP(3)); (4000001,5118130,0,CURRENT_TIMESTAMP(3));
@@ -19,7 +19,14 @@ public class PlatformAnalyticsController {
public static final String READ_PERMISSION="platform:self-operated-analytics:list"; public static final String READ_PERMISSION="platform:self-operated-analytics:list";
private final AnalyticsQueryService service; private final AnalyticsQueryService service;
private final CollectionQueryService collection; private final CollectionQueryService collection;
public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection){this.service=service;this.collection=collection;} private final DashboardQueryService dashboard;
public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection,DashboardQueryService dashboard){this.service=service;this.collection=collection;this.dashboard=dashboard;}
@GetMapping("/dashboard")
@Operation(summary="同仓固定版本的经营总览图表与精确下钻上下文")
public ApiResponse<DashboardResponses.Dashboard> dashboard(@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,
@RequestParam java.util.Map<String,String> parameters){
requirePlatform(user);DashboardRequest.validateParameterNames(parameters.keySet());return ApiResponse.success(dashboard.dashboard(query));
}
@GetMapping("/sku-sales-summary") @GetMapping("/sku-sales-summary")
@Operation(summary="五周期或历史自定义范围SKU累计清单") @Operation(summary="五周期或历史自定义范围SKU累计清单")
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> salesSummary( public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> salesSummary(
@@ -4,11 +4,14 @@ import com.baiyue.common.core.exception.BizException;
import java.util.Set; import java.util.Set;
/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */ /** 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 attentionValue(){return attention==null?"ALL":attention;}
public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;} public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;}
public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
public void validate(String endpoint,AnalyticsQuery q){ 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(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("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效");
if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效"); if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效");
@@ -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);} +" 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 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 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," 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 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," +"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)," +"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," +" 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," +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 " +defectiveQuantity()+" defective_physical_quantity,MAX(observed_at_utc) observed_at_utc "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END 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)," +"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," +" 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 " +"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) "; +"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 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 "NO_OUTBOUND_30D" -> " WHERE no_outbound=1";
case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1"; case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1";
case "FROZEN_STOCK" -> " WHERE frozen_quantity>0"; 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"); .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);} return typed(paged+"SELECT k.customer_code,k.sku_code,k.product_name,k.unit_valid,k.evidence_at_utc,k.physical_quantity,k.available_quantity,k.frozen_quantity,k.transit_quantity,k.defective_physical_quantity,k.observed_at_utc,k.owner_outbound,k.observed_outbound,k.order_count,l.last_outbound,k.observation_eligible,k.outbound_quantity30d,k.coverage_days,k.no_outbound,k.above_threshold,k.unassessable FROM paged k LEFT JOIN last_sales l ON l.customer_code=k.customer_code AND l.sku_code=k.sku_code ORDER BY "+outerOrder);}
public static String inventoryCount(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); public static String inventoryCount(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o");
return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));} if(o.coverageBucket()==null&&o.coverageSelection()==null&&java.util.Set.of("FROZEN_STOCK","DEFECTIVE_STOCK").contains(o.attentionValue()))
return typed(stockAttentionCount(p,o));
return typed("ALL".equals(o.attentionValue())&&o.coverageBucket()==null&&o.coverageSelection()==null?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));}
/** These predicates depend only on stock. Counting them must not aggregate sales
* or inspect the other profile version, even when those sales are unavailable. */
private static String stockAttentionCount(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
String amount="FROZEN_STOCK".equals(o.attentionValue())?quantity("frozen_quantity"):defectiveQuantity();
return "SELECT COUNT(*) FROM (SELECT customer_code,sku_code,"+amount+" amount FROM ll_selfop_inventory_snapshot "
+"WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code) st "
+"JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} "
+"AND i.customer_code=st.customer_code AND i.sku_code=st.sku_code WHERE #{p.stockFresh}=1 AND i.quantity_unit='PCS' AND st.amount>0";
}
public static String ownerTotals(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count"); public static String ownerTotals(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count");
for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")) 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"); 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 '!'");} private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");}
public static String owners(Map<String,Object> args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");} public static String owners(Map<String,Object> args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");}
public static String ownerCount(Map<String,Object> args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");} public static String ownerCount(Map<String,Object> args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");}
private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");} static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");}
} }
@@ -25,7 +25,7 @@ public class AnalyticsQueryService {
@Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7; @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());} @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;} 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){} LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){}
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){ public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){
@@ -68,6 +68,7 @@ public class AnalyticsQueryService {
public AnalyticsResponses.Page<AnalyticsResponses.Metric> slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);} public AnalyticsResponses.Page<AnalyticsResponses.Metric> slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);}
public AnalyticsResponses.Page<AnalyticsResponses.Metric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){ public AnalyticsResponses.Page<AnalyticsResponses.Metric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY"); 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); 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()); 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); Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind);
List<AnalyticsReadMapper.RankingStat> statistics=mapper.rankingStats(p); List<AnalyticsReadMapper.RankingStat> statistics=mapper.rankingStats(p);
long total=statistics.isEmpty()?0:statistics.get(0).total(); long total=statistics.isEmpty()?0:statistics.get(0).total();
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList(); List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind)).toList();
Map<String,Long> excluded=new TreeMap<>(); Map<String,Long> excluded=new TreeMap<>();
for(var e:statistics)if(e.reason()!=null)excluded.put(e.reason(),e.excludedCount()); for(var e:statistics)if(e.reason()!=null)excluded.put(e.reason(),e.excludedCount());
return page(s,rows,total,excluded); 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(), 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); 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()); Instant evidence=utc(r.evidenceAtUtc());
boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant()); boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant());
boolean stockValid="VALID".equals(r.inventoryQuality()); boolean stockValid="VALID".equals(r.inventoryQuality());
@@ -161,7 +162,7 @@ public class AnalyticsQueryService {
if(!"VALID".equals(result.status()))reasons.add(result.status()); if(!"VALID".equals(result.status()))reasons.add(result.status());
if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); if(r.inventoryReason()!=null)reasons.add(r.inventoryReason());
BigDecimal today=r.todayQuantity(); 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 start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant();
Instant lower=evidence.isAfter(start)?evidence:start; Instant lower=evidence.isAfter(start)?evidence:start;
if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom(); 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(), 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()); 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(); q.validate(chart,exact,hot); Instant now=clock.instant();
List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode()); List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode());
Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY"); 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)); 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); 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())); 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(), 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()), 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.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); 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){ private Run resolve(Scope scope,Long requested,String hash){
if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;} 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; Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null;
@@ -29,7 +29,8 @@ public final class AnalyticsReadSql {
return sql; return sql;
} }
private static String cte(String kind){ 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 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 observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')";
String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')"; String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')";
@@ -166,11 +167,62 @@ public final class AnalyticsReadSql {
} }
public static String page(java.util.Map<String,Object> parameters){ public static String page(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p"); 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," 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," +"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 " +"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); +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p);
} }
/** Rank on sales alone. Inventory enrichment and its quality checks touch only the
* selected page; inventory missing/invalid units cannot erase a valid sales rank. */
private static String hotSalesCte(){
String profileValid="(s.sku_code IS NULL OR s.quantity_unit='PCS')";
String windowValid=profileValid+" AND COALESCE(m.window_valid,1)=1";
String thirtyValid=profileValid+" AND COALESCE(m.thirty_valid,1)=1";
String todayValid=profileValid+" AND COALESCE(m.today_valid,1)=1";
return "WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER
+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+"),"
+" metric_sales AS (SELECT customer_code,sku_code,"
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity,"
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders,"
+"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity,"
+"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity END) today_quantity,"
+"MIN(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) window_valid,"
+"MIN(CASE WHEN business_date<#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) thirty_valid,"
+"MIN(CASE WHEN business_date=#{p.anchor} THEN CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END ELSE 1 END) today_valid "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER
+"AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<TIMESTAMPADD(DAY,1,#{p.anchor}) GROUP BY customer_code,sku_code),"
+" result AS (SELECT c.customer_code,c.sku_code,CASE WHEN "+windowValid+" THEN 1 ELSE 0 END unit_valid,"
+"CASE WHEN "+windowValid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_quantity,0) END window_quantity,"
+"CASE WHEN "+windowValid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_orders,0) END window_sku_order_count,"
+"CASE WHEN "+thirtyValid+" AND #{p.complete30}=1 THEN COALESCE(m.sales30_quantity,0) END sales30_quantity,"
+"CASE WHEN "+todayValid+" THEN 1 ELSE 0 END today_unit_valid,"
+"CASE WHEN "+todayValid+" AND #{p.salesKnown}=1 THEN m.today_quantity END today_quantity "
+"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} "
+"AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code LEFT JOIN metric_sales m ON m.customer_code=c.customer_code AND m.sku_code=c.sku_code) ";
}
private static String hotPage(){
String salesEvidence="CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END";
String stockEvidence="CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END";
String evidence="CASE WHEN ("+salesEvidence+") IS NULL THEN "+stockEvidence+" WHEN ("+stockEvidence+") IS NULL THEN "+salesEvidence
+" ELSE LEAST("+salesEvidence+","+stockEvidence+") END";
String todayStart="CONVERT_TZ(CAST(#{p.anchor} AS DATETIME),'+08:00','+00:00')";
String today="CASE WHEN k.today_unit_valid=1 AND #{p.salesKnown}=1 THEN CASE WHEN k.today_quantity IS NOT NULL THEN k.today_quantity "
+"WHEN ("+evidence+") IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST("+todayStart+",("+evidence+"),COALESCE(#{p.coveredFrom},"+todayStart+"))<#{p.coveredUntil} THEN 0 END END";
return hotSalesCte()+", paged AS (SELECT * FROM result WHERE window_quantity>0 ORDER BY "+order("HOT")+" LIMIT #{p.size} OFFSET #{p.offset}) "
+"SELECT k.customer_code,k.sku_code,COALESCE(i.product_name,s.product_name) product_name,'PCS' quantity_unit,"
+evidence+" evidence_at_utc,k.unit_valid,"
+"CAST(NULL AS DECIMAL(20,6)) observed_quantity,CAST(NULL AS SIGNED) sku_order_count,0 complete_day_count,0 partial_day_count,0 unknown_day_count,"
+"k.window_quantity,k.window_sku_order_count,k.sales30_quantity,"+today+" today_quantity,st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc,"
+"CASE WHEN st.sku_code IS NULL OR i.quantity_unit IS NULL OR i.quantity_unit<>'PCS' THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' "
+"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' "
+"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality,"
+"CASE WHEN i.quantity_unit IS NULL OR i.quantity_unit<>'PCS' THEN 'UNIT_UNKNOWN_OR_CONFLICT' ELSE st.quality_reason END inventory_reason "
+"FROM paged k LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=k.customer_code AND s.sku_code=k.sku_code "
+"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=k.customer_code AND i.sku_code=k.sku_code "
+"LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} AND st.warehouse_code=#{p.warehouse} AND st.customer_code=k.customer_code AND st.sku_code=k.sku_code AND st.product_quality=0 "
+"ORDER BY k.window_quantity DESC,k.window_sku_order_count DESC,k.sku_code ASC,k.customer_code ASC";
}
public static String count(java.util.Map<String,Object> parameters){ public static String count(java.util.Map<String,Object> parameters){
var p=(AnalyticsSqlRequest)parameters.get("p"); var p=(AnalyticsSqlRequest)parameters.get("p");
// Candidate totals do not scan daily rows for normal lists. // Candidate totals do not scan daily rows for normal lists.
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.time.Instant;
import java.util.List;
/** V1's partition and strict read lifetime are a local report contract, never an authorization. */
public final class CoverageDistributionPolicy {
public static final String RULE_VERSION="COVERAGE_DISTRIBUTION_V1";
public static final List<String> BUCKETS=List.of("DAYS_0_30","DAYS_30_60","DAYS_60_90","DAYS_GT_90","NO_OUTBOUND_30D","NO_AVAILABLE","UNKNOWN");
private CoverageDistributionPolicy(){}
public static void validate(AnalyticsInventoryOptions o){
if(o.coverageBucket()!=null&&(!BUCKETS.contains(o.coverageBucket())||!RULE_VERSION.equals(o.bucketRuleVersion())))throw new BizException("库存分布桶或规则版本无效");
if(o.coverageBucket()==null&&o.bucketRuleVersion()!=null)throw new BizException("分布规则版本必须与分桶条件一起提供");
if(o.coverageSelection()!=null&&!"FINITE_POSITIVE".equals(o.coverageSelection()))throw new BizException("库存有限天数筛选无效");
if(o.coverageBucket()!=null&&(o.coverageSelection()!=null||!"ALL".equals(o.attentionValue()))||o.coverageSelection()!=null&&!"ALL".equals(o.attentionValue()))throw new BizException("分桶、有限天数与关注条件互斥");
if(o.drilldownFrom()!=null&&!"DASHBOARD".equals(o.drilldownFrom()))throw new BizException("库存下钻来源无效");
}
public static Instant expiresAt(AnalyticsResponses.Context context,Instant freshUntil){
if(context.inventoryRunId()==null||context.inventoryRunId()==0||!"VALID".equals(context.inventoryStatus())||freshUntil==null)return null;
return freshUntil.isBefore(context.queryExpiresAt())?freshUntil:context.queryExpiresAt();
}
public static void requireDrilldown(AnalyticsQuery q,AnalyticsInventoryOptions o,AnalyticsResponses.Context context,Instant freshUntil,Instant now){
if(!"DASHBOARD".equals(o.drilldownFrom()))return;
if(!q.pinned())throw new BizException("经营总览下钻必须携带原完整版本上下文");
Instant expires=expiresAt(context,freshUntil);
if(expires==null||!now.isBefore(expires))throw new BizException("原图库存已过期,请刷新总览");
}
}
@@ -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 "";
}
}
@@ -0,0 +1,90 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.*;
import java.util.*;
@Service
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public class DashboardQueryService {
private final AnalyticsQueryService contextService;
private final DashboardReadMapper mapper;
public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper){this.contextService=contextService;this.mapper=mapper;}
public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){
AnalyticsQuery q=DashboardRequest.normalize(input);
var snapshot=contextService.context(q,true,false,false);var c=snapshot.context();
var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate();
var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom,
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD");
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);
var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive());
Map<String,Object> stats=normalize(mapper.statistics(p,o));var ranked=mapper.rankings(p,o);
var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days);
var trendSummary=SalesBucketPolicy.aggregate(trendPoints);
var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days);var fixed=SalesBucketPolicy.aggregate(fixedPoints);
var salesSummary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),null,null,null,"PCS",trendSummary.outboundQuantity(),trendSummary.observedQuantity(),
trendSummary.dataStatus(),trendSummary.reasonCodes(),trendSummary.completeDays(),trendSummary.partialDays(),trendSummary.unknownDays());
var points=trendPoints.stream().map(t->new AnalyticsResponses.Point(t.date(),t.outboundQuantity(),t.observedQuantity(),t.dataStatus(),t.reasonCodes(),"PCS",t.provedFrom(),t.provedUntil())).toList();
var interval=new DashboardResponses.Metric(trendSummary.outboundQuantity(),trendSummary.observedQuantity(),"PCS",trendSummary.dataStatus(),null,trendSummary.reasonCodes(),trendSummary.outboundQuantity(),trendSummary.observedQuantity());
// A partial observed quantity is never divided by 30 and presented as an estimated daily average.
var average=new DashboardResponses.Metric(divide30(fixed.outboundQuantity()),divide30(fixed.outboundQuantity()),"PCS_PER_DAY",fixed.dataStatus(),null,fixed.reasonCodes(),null,null);
boolean inventoryAvailable=base.inventoryRun()>0;
var kpis=new DashboardResponses.Kpis(interval,average,stockMetric(stats,"physical_quantity_known","physical_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c),
stockMetric(stats,"available_quantity_known","available_quantity_unknown","PCS",inventoryAvailable,base.stockFresh(),c),
stockMetric(stats,"physical_skus","physical_quantity_unknown","SKU",inventoryAvailable,base.stockFresh(),c),
stockMetric(stats,"no_outbound_eligible","no_outbound_unknown","SKU",inventoryAvailable,base.stockFresh(),c));
Instant freshUntil=contextService.inventoryFreshUntil(snapshot),drilldownUntil=CoverageDistributionPolicy.expiresAt(c,freshUntil);
return new DashboardResponses.Dashboard(new DashboardResponses.Context(c,fixedFrom,fixedUntil,30,freshUntil,drilldownUntil),kpis,
new DashboardResponses.SalesTrend(salesSummary,points),distribution(stats,inventoryAvailable,c),
ranking("HOT",stats,ranked,base.salesRun()>0,fixedFrom,fixedUntil,c),
ranking("LONG",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c),
ranking("NO_OUTBOUND",stats,ranked,inventoryAvailable,fixedFrom,fixedUntil,c));
}
private static DashboardResponses.Metric stockMetric(Map<String,Object> s,String knownField,String unknownField,String unit,boolean available,boolean fresh,AnalyticsResponses.Context c){
if(!available)return new DashboardResponses.Metric(null,null,unit,"UNKNOWN",null,List.of("INVENTORY_UNAVAILABLE"),null,null);
long unknown=count(s,unknownField);BigDecimal known=decimal(s,knownField);
if(count(s,"total_skus")==0&&fresh)known=BigDecimal.ZERO;
if(!fresh&&count(s,"total_skus")==0)known=null;
BigDecimal value=fresh&&unknown==0?known:null;
String status=value!=null?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";
List<String> reasons=value!=null?List.of():!fresh?List.of("INVENTORY_"+c.inventoryStatus()):List.of("METRIC_DEPENDENCY_UNKNOWN");
return new DashboardResponses.Metric(value,known,unit,status,unknown,reasons,null,null);
}
private static DashboardResponses.Distribution distribution(Map<String,Object> s,boolean available,AnalyticsResponses.Context c){
List<DashboardResponses.Bucket> buckets=new ArrayList<>();
for(String code:CoverageDistributionPolicy.BUCKETS){String suffix=code.toLowerCase(Locale.ROOT);long n=count(s,"bucket_"+suffix);BigDecimal physical=decimal(s,"physical_"+suffix);
if(n==0&&available)physical=BigDecimal.ZERO;
buckets.add(new DashboardResponses.Bucket(code,available?n:null,available?physical:null,available?count(s,"unknown_"+suffix):null));
}
Map<String,Long> reasons=new LinkedHashMap<>();s.forEach((key,value)->{if(key.startsWith("reason_")&&value!=null){long n=new BigDecimal(value.toString()).longValueExact();if(n>0)reasons.put(key.substring(7).toUpperCase(Locale.ROOT),n);}});
String status=!available?"UNKNOWN":count(s,"bucket_unknown")>0?"PARTIAL":"COMPLETE";
List<String> codes=!available?List.of("INVENTORY_UNAVAILABLE"):count(s,"bucket_unknown")>0?List.of("UNASSESSABLE_SKUS"):List.of();
if(available&&!"VALID".equals(c.inventoryStatus())){status="PARTIAL";codes=List.of("INVENTORY_"+c.inventoryStatus());}
return new DashboardResponses.Distribution(CoverageDistributionPolicy.RULE_VERSION,available?count(s,"candidate_skus"):null,
available?count(s,"excluded_zero_skus"):null,List.copyOf(buckets),status,codes,available?Map.copyOf(reasons):Map.of());
}
private static DashboardResponses.Ranking ranking(String kind,Map<String,Object> s,List<DashboardReadMapper.Ranked> rows,boolean available,LocalDate from,LocalDate until,AnalyticsResponses.Context c){
String prefix=switch(kind){case "HOT"->"hot";case "LONG"->"long";default->"no_outbound";};
List<DashboardReadMapper.Ranked> selected=rows.stream().filter(r->kind.equals(r.rankCode())).sorted(Comparator
.comparing(DashboardReadMapper.Ranked::mainValue,Comparator.reverseOrder())
.thenComparing(r->"HOT".equals(kind)?r.orderCount():0L,Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(DashboardReadMapper.Ranked::skuCode).thenComparing(DashboardReadMapper.Ranked::customerCode)).toList();
var items=available?selected.stream().map(r->new DashboardResponses.Item(r.customerCode(),r.skuCode(),r.productName(),r.mainValue(),"LONG".equals(kind)?"DAY":"PCS",
"HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d(),r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.defectivePhysicalQuantity(),
divide30("HOT".equals(kind)?r.ownerOutbound():r.outboundQuantity30d()),r.coverageDays())).toList():List.<DashboardResponses.Item>of();
long unknown=count(s,prefix+"_unknown");String status=!available?"UNKNOWN":unknown>0?"PARTIAL":"COMPLETE";
List<String> reasons=!available?List.of("HOT".equals(kind)?"SALES_UNAVAILABLE":"INVENTORY_UNAVAILABLE"):unknown>0?List.of("UNASSESSABLE_SKUS"):List.of();
if(available&&count(s,prefix+"_eligible")==0&&unknown==0)reasons=List.of("NO_ELIGIBLE_SKUS");
if(available&&"HOT".equals(kind)&&!"VALID".equals(c.salesStatus())){status="UNKNOWN";reasons=List.of("SALES_"+c.salesStatus());}
return new DashboardResponses.Ranking(from,until,30,available?count(s,prefix+"_eligible"):null,available?unknown:null,items,status,reasons);
}
private static Map<String,Object> normalize(Map<String,Object> source){Map<String,Object> result=new HashMap<>();if(source!=null)source.forEach((key,value)->result.put(key.toLowerCase(Locale.ROOT),value));return result;}
private static BigDecimal decimal(Map<String,Object> source,String key){Object value=source.get(key);return value==null?null:value instanceof BigDecimal decimal?decimal:new BigDecimal(value.toString());}
private static long count(Map<String,Object> source,String key){BigDecimal value=decimal(source,key);return value==null?0:value.longValueExact();}
private static BigDecimal divide30(BigDecimal value){return value==null?null:value.divide(BigDecimal.valueOf(30),12,RoundingMode.HALF_UP);}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.*;
@Mapper
public interface DashboardReadMapper {
record Day(LocalDate businessDate,BigDecimal observedQuantity,Long invalidRowCount){}
record Ranked(String rankCode,String customerCode,String skuCode,String productName,BigDecimal mainValue,
BigDecimal ownerOutbound,Long orderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,
BigDecimal frozenQuantity,BigDecimal defectivePhysicalQuantity,BigDecimal outboundQuantity30d,BigDecimal coverageDays){}
@Select("SELECT business_date,SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN outbound_quantity END) observed_quantity,"
+"COUNT(CASE WHEN quantity_unit IS NULL OR quantity_unit<>'PCS' OR outbound_quantity IS NULL OR outbound_quantity<0 THEN 1 END) invalid_row_count "
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND source_account=#{p.account} AND warehouse_code=#{p.warehouse} "
+"AND ((business_date>=#{chartFrom} AND business_date<#{chartUntil}) OR (business_date>=#{p.from} AND business_date<#{p.until})) GROUP BY business_date ORDER BY business_date")
List<Day> daily(@Param("p") AnalyticsSqlRequest request,@Param("chartFrom") LocalDate chartFrom,@Param("chartUntil") LocalDate chartUntil);
@SelectProvider(type=DashboardReadSql.class,method="statistics")
Map<String,Object> statistics(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
@SelectProvider(type=DashboardReadSql.class,method="rankings")
List<Ranked> rankings(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options);
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.List;
import java.util.Map;
/** Three bounded aggregate queries: warehouse daily points, one statistics row, at most 30 ranked rows. */
public final class DashboardReadSql {
private DashboardReadSql(){}
public static String statistics(Map<String,Object> args){
var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(AnalyticsInventorySql.cte(p));
sql.append(", classified AS (SELECT r.*,CASE WHEN ").append(CoverageDistributionSql.candidate()).append(" THEN ")
.append(CoverageDistributionSql.bucket()).append(" END bucket_code,").append(CoverageDistributionSql.unknownReason()).append(" unknown_reason FROM result r) SELECT COUNT(*) total_skus");
for(String field:List.of("physical_quantity","available_quantity"))sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
sql.append(",COUNT(CASE WHEN physical_quantity>0 THEN 1 END) physical_skus,COUNT(CASE WHEN physical_quantity=0 THEN 1 END) excluded_zero_skus")
.append(",COUNT(CASE WHEN bucket_code IS NOT NULL THEN 1 END) candidate_skus")
.append(",COUNT(CASE WHEN owner_outbound>0 THEN 1 END) hot_eligible,COUNT(CASE WHEN owner_outbound IS NULL THEN 1 END) hot_unknown")
.append(",COUNT(CASE WHEN ").append(CoverageDistributionSql.finitePositive()).append(" THEN 1 END) long_eligible")
.append(",COUNT(CASE WHEN available_quantity IS NULL OR (available_quantity>0 AND outbound_quantity30d IS NULL) THEN 1 END) long_unknown")
.append(",COUNT(CASE WHEN no_outbound=1 THEN 1 END) no_outbound_eligible,COUNT(CASE WHEN no_outbound IS NULL THEN 1 END) no_outbound_unknown");
for(String code:CoverageDistributionPolicy.BUCKETS){String alias=code.toLowerCase(java.util.Locale.ROOT);
sql.append(",COUNT(CASE WHEN bucket_code='").append(code).append("' THEN 1 END) bucket_").append(alias)
.append(",SUM(CASE WHEN bucket_code='").append(code).append("' THEN physical_quantity END) physical_").append(alias)
.append(",COUNT(CASE WHEN bucket_code='").append(code).append("' AND physical_quantity IS NULL THEN 1 END) unknown_").append(alias);
}
for(String reason:List.of("PHYSICAL_INVENTORY_UNKNOWN","AVAILABLE_INVENTORY_UNKNOWN","UNIT_UNKNOWN_OR_CONFLICT","SALES_WINDOW_UNAVAILABLE","OBSERVATION_INSUFFICIENT","METRIC_DEPENDENCY_UNKNOWN"))
sql.append(",COUNT(CASE WHEN bucket_code='UNKNOWN' AND unknown_reason='").append(reason).append("' THEN 1 END) reason_").append(reason.toLowerCase(java.util.Locale.ROOT));
return AnalyticsInventorySql.typed(sql+" FROM classified");
}
public static String rankings(Map<String,Object> args){var p=(AnalyticsSqlRequest)args.get("p");
String sql=AnalyticsInventorySql.cte(p)
+", hot_rank AS (SELECT * FROM result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)"
+", long_rank AS (SELECT * FROM result WHERE "+CoverageDistributionSql.finitePositive()+" ORDER BY coverage_days DESC,sku_code ASC,customer_code ASC LIMIT 10)"
+", no_rank AS (SELECT * FROM result WHERE no_outbound=1 ORDER BY physical_quantity DESC,sku_code ASC,customer_code ASC LIMIT 10) ";
sql+=select("HOT","owner_outbound","hot_rank")+" UNION ALL "+select("LONG","coverage_days","long_rank")+" UNION ALL "+select("NO_OUTBOUND","physical_quantity","no_rank");
return AnalyticsInventorySql.typed(sql);
}
private static String select(String kind,String value,String table){return "SELECT '"+kind+"' rank_code,customer_code,sku_code,product_name,"+value+" main_value,owner_outbound,order_count,physical_quantity,available_quantity,frozen_quantity,defective_physical_quantity,outbound_quantity30d,coverage_days FROM "+table;}
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import java.util.Set;
public final class DashboardRequest {
private DashboardRequest(){}
public static void validateParameterNames(Set<String> names){
if(!Set.of("sourceAccount","warehouseCode","rangePreset","startDate","endDate","bucket","salesRunId","inventoryRunId","salesScopeHash","inventoryScopeHash","queryAsOf").containsAll(names))
throw new BizException("经营总览不接受货主、SKU、分页、任意榜单数量或其他筛选条件");
}
public static AnalyticsQuery normalize(AnalyticsQuery q){
if(q.customerCode()!=null||q.sku()!=null||q.current()!=null||q.size()!=null||q.windowDays()!=null)throw new BizException("经营总览仅支持仓库、趋势范围与固定版本上下文");
return new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,q.rangePreset()==null?"LAST_15_DAYS":q.rangePreset(),q.startDate(),q.endDate(),q.bucket(),null,null,null,
q.salesRunId(),q.inventoryRunId(),q.salesScopeHash(),q.inventoryScopeHash(),q.queryAsOf());
}
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public final class DashboardResponses {
private DashboardResponses(){}
public record Context(@JsonUnwrapped AnalyticsResponses.Context versionContext,LocalDate fixedWindowStartDate,
LocalDate fixedWindowEndDateExclusive,int fixedWindowDays,Instant inventoryFreshUntil,Instant inventoryDrilldownExpiresAt){}
public record Metric(BigDecimal value,BigDecimal knownSubtotal,String unit,String dataStatus,Long unknownSkuCount,
List<String> reasonCodes,BigDecimal outboundQuantity,BigDecimal observedQuantity){}
public record Kpis(Metric intervalOutboundQuantity,Metric averageDailyOutboundQuantity30d,Metric physicalQuantity,
Metric availableQuantity,Metric physicalSkuCount,Metric noOutboundSkuCount30d){}
public record SalesTrend(AnalyticsResponses.SalesSummary summary,List<AnalyticsResponses.Point> points){}
public record Bucket(String bucketCode,Long skuCount,BigDecimal knownPhysicalQuantity,Long unknownPhysicalSkuCount){}
public record Distribution(String bucketRuleVersion,Long candidateSkuCount,Long excludedZeroPhysicalSkuCount,
List<Bucket> buckets,String dataStatus,List<String> reasonCodes,Map<String,Long> unknownReasons){}
public record Item(String customerCode,String sku,String productName,BigDecimal value,String unit,BigDecimal outboundQuantity30d,
Long skuOutboundOrderCount,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,
BigDecimal defectivePhysicalQuantity,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays){}
public record Ranking(LocalDate windowStartDate,LocalDate windowEndDateExclusive,int calculationWindowDays,Long eligibleCount,
Long unassessableCount,List<Item> items,String dataStatus,List<String> reasonCodes){}
public record Dashboard(Context context,Kpis kpis,SalesTrend salesTrend,Distribution coverageDistribution,
Ranking hotSkus,Ranking longCoverageSkus,Ranking noOutboundSkus){}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
/** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */
public final class DashboardSalesPolicy {
private DashboardSalesPolicy(){}
public static List<SalesBucketPolicy.Point> points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List<DashboardReadMapper.Day> rows){
Map<LocalDate,DashboardReadMapper.Day> days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row);
List<SalesBucketPolicy.Point> result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai");
for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){
var actual=days.get(day);Instant start=day.atStartOfDay(zone).toInstant(),end=day.plusDays(1).atStartOfDay(zone).toInstant();
Instant lower=c.salesCoveredFrom()==null?null:(c.salesCoveredFrom().isAfter(start)?c.salesCoveredFrom():start);
Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end);
boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper);
boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0;
boolean trusted="VALID".equals(c.salesStatus())&&!c.rebuildRequired();
boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&c.completeDayFrom()!=null&&c.completeDayUntil()!=null
&&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end);
BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null;
if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO;
String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List<String> reasons=new ArrayList<>();
if(!sourceKnown)reasons.add("SALES_NOT_COVERED");if(!trusted)reasons.add("SALES_"+c.salesStatus());
if(invalid)reasons.add("SALES_QUANTITY_OR_UNIT_INVALID");if(!complete)reasons.add(day.equals(c.anchorDate())?"TODAY_INCOMPLETE":"SALES_DAY_INCOMPLETE");
if(known==null)reasons.add("SALES_NOT_COVERED");
result.add(new SalesBucketPolicy.Point(day,complete?known:null,known,null,status,List.copyOf(new LinkedHashSet<>(reasons)),intersects?lower:null,intersects?upper:null));
}
return List.copyOf(result);
}
}
@@ -84,7 +84,7 @@ public class SelfopDiscoveryService {
}); });
} }
} }
route(task,manifest,lease); route(task,manifest,lease,false);
for(var entry:manifest.getObjects().entrySet()) { for(var entry:manifest.getObjects().entrySet()) {
var ref=entry.getValue(); var ref=entry.getValue();
if(ref.getRefreshTaskId()!=null){ 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); 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);} 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(); tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow();
SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run); 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); 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()?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);} manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);}
tasks.updateById(task);} 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 // Refresh commands only replace a parent manifest reference; never create an independent
// fact writer that could publish around that parent's half-applied barrier. // fact writer that could publish around that parent's half-applied barrier.
if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return; if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return;
tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return; tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return;
boolean accountDetailsReady=detailsResolved&&manifest.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null);
for(var scope:scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){ for(var scope:scopes.selectList(new QueryWrapper<SelfopSyncScope>().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){
if(!config.getWarehouses().containsKey(scope.getWarehouseCode()))continue; if(!config.getWarehouses().containsKey(scope.getWarehouseCode()))continue;
var own=new SelfopRoutingManifest();own.setDirectoryComplete(true);own.setTotal(0L);own.setPages(manifest.getPages()); 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()); own.setTotal((long)own.getObjects().size());
boolean ready=scope.getSourceTimezone()!=null&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())) boolean ready=accountDetailsReady&&scope.getSourceTimezone()!=null
&&own.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=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()); 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.setExecutionPurpose(parent.getExecutionPurpose());
child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline()); child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline());
@@ -21,6 +21,39 @@ info:
重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。
security: [{bearerAuth: []}] security: [{bearerAuth: []}]
paths: 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: /api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary:
get: get:
summary: SKU五周期或历史累计清单 summary: SKU五周期或历史累计清单
@@ -149,6 +182,10 @@ paths:
summary: 正品库存与固定30日可售天数 summary: 正品库存与固定30日可售天数
description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
parameters: parameters:
- {$ref: '#/components/parameters/coverageBucket'}
- {$ref: '#/components/parameters/bucketRuleVersion'}
- {$ref: '#/components/parameters/coverageSelection'}
- {$ref: '#/components/parameters/drilldownFrom'}
- {$ref: '#/components/parameters/attention'} - {$ref: '#/components/parameters/attention'}
- {$ref: '#/components/parameters/sortBy'} - {$ref: '#/components/parameters/sortBy'}
- {$ref: '#/components/parameters/coverageDaysThreshold'} - {$ref: '#/components/parameters/coverageDaysThreshold'}
@@ -338,6 +375,14 @@ components:
securitySchemes: securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters: 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: 区分大小写的货主编码字面前缀,先过滤去重再分页} 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]}} 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和货主升序} 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}} 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}} skuRequired: {name: sku, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
schemas: 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: OwnerOption:
type: object type: object
properties: properties:
@@ -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));} private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));}
@Test void rejectsCustomerEvenWithReadPermission(){ @Test void rejectsCustomerEvenWithReadPermission(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); 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)); 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.inventoryCoverage(user,null,null));
assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null)); assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null));
@@ -31,7 +31,7 @@ class PlatformAnalyticsControllerTest {
} }
@Test void writePermissionAloneDoesNotAuthorizeAnyRead(){ @Test void writePermissionAloneDoesNotAuthorizeAnyRead(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); 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")); 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.hotSkus(user,null));
assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null)); assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null));
@@ -40,7 +40,7 @@ class PlatformAnalyticsControllerTest {
} }
@Test void allElevenReadEndpointsShareTheIndependentGuard(){ @Test void allElevenReadEndpointsShareTheIndependentGuard(){
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); 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.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); 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); verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null);
@@ -56,7 +56,7 @@ class PlatformAnalyticsControllerTest {
} }
@Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{ @Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); 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") 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("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") .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{ @Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); 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") mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO")
.param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk()); .param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk());
var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture()); 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{ @Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); 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") 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()); .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); 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()); assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold());
verifyNoInteractions(collection); 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());
}
} }
@@ -247,14 +247,24 @@ class AnalyticsQueryServiceTest {
} }
@Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){ @Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){
var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null); 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.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,"COVERAGE_GT_THRESHOLD",null,null,null,null,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,"physical;delete",1,null,null,null,null)));
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0))); assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null)));
verifyNoInteractions(mapper); 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){ 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); 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, return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
@@ -77,7 +77,8 @@ class AnalyticsReadMapperTest {
assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))")); assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
assertTrue(sql.contains("GROUP BY customer_code,sku_code")); assertTrue(sql.contains("GROUP BY customer_code,sku_code"));
assertTrue(sql.indexOf("GROUP BY customer_code,sku_code")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot")); assertTrue(sql.indexOf("GROUP BY customer_code,sku_code")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql); if("HOT".equals(kind))assertTrue(sql.indexOf("LIMIT ? OFFSET ?")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
else assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql);
} }
String hot=bound(c,"page",Map.of("p",request("HOT"))).getSql(); String hot=bound(c,"page",Map.of("p",request("HOT"))).getSql();
assertTrue(hot.contains("FROM result WHERE window_quantity>0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT")); assertTrue(hot.contains("FROM result WHERE window_quantity>0 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("LEAST(SUM(CASE WHEN eligible=1 THEN amount ELSE 0 END) OVER (),100) total"));
assertTrue(stats.contains("excluded_count"));assertFalse(stats.contains(" LIMIT ")); assertTrue(stats.contains("excluded_count"));assertFalse(stats.contains(" LIMIT "));
assertFalse(stats.contains("ranged AS"));assertFalse(stats.contains("TIMESTAMPDIFF")); assertFalse(stats.contains("ranged AS"));assertFalse(stats.contains("TIMESTAMPDIFF"));
if("HOT".equals(kind))assertFalse(stats.contains("ll_selfop_inventory_snapshot"));
} }
} }
@Test void inventoryHistoryOnlyEnrichesQualifiedPageAndNeverCountsOrOwnerTotals(){ @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(); 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.contains("paged AS (SELECT * FROM result WHERE above_threshold=1 ORDER BY"));
assertTrue(page.indexOf("LIMIT ? OFFSET ?")<page.indexOf("last_sales AS")); assertTrue(page.indexOf("LIMIT ? OFFSET ?")<page.indexOf("last_sales AS"));
@@ -103,6 +105,19 @@ class AnalyticsReadMapperTest {
assertFalse(bound(c,"ownerTotals",Map.of("p",p,"o",o)).getSql().contains("last_sales")); assertFalse(bound(c,"ownerTotals",Map.of("p",p,"o",o)).getSql().contains("last_sales"));
immutableOnly(page); immutableOnly(page);
} }
@Test void distributionCountCannotUseUnfilteredKeysAndStockOnlyCountSkipsSales(){
var c=config();var p=request("INVENTORY");
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"DAYS_0_30","COVERAGE_DISTRIBUTION_V1",null,null),
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null))){
String count=bound(c,"inventoryCount",Map.of("p",p,"o",o)).getSql();
assertTrue(count.contains("FROM result WHERE"));assertTrue(count.contains("ll_selfop_sku_daily_sales"));
}
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
String count=bound(c,"inventoryCount",Map.of("p",p,"o",new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null))).getSql();
assertFalse(count.contains("ll_selfop_sku_daily_sales"));assertTrue(count.contains("i.quantity_unit='PCS'"));
assertTrue(count.contains("snapshot_run_id=?"));assertTrue(count.contains("st.amount>0"));
}
}
@Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){ @Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){
String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql(); 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")); assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL"));
@@ -94,7 +94,9 @@ public class AnalyticsSqlSemanticTest {
assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0)); assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0));
var row=rows.get(0); var row=rows.get(0);
assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount()); 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 { @Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); 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()); assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d());
assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount()); 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 { @Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING"); 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()); 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()); 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 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()); assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
Map<String,Object> totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue()); Map<String,Object> 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")); 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); 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"); 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()); var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound());
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30)); Map<String,Object> 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()); 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")); 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"); 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); 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%"),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))); 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))); 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)).get(0)).longValue()); assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)).get(0)).longValue());
} }
@Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception { @Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception {
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX"); 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); 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 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()); assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
} }
@Test void yearlySparseSummaryPreservesOwnerIdentityAndCompletePartialUnknownSpanCounts() throws Exception { @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","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","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"); 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 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)); 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(1L,hotStats.stream().filter(s->"UNIT_UNKNOWN_OR_CONFLICT".equals(s.reason())).findFirst().orElseThrow().excludedCount());
assertEquals("B",page(hot).get(0).customerCode()); assertEquals("B",page(hot).get(0).customerCode());
var slowStats=rankingStats(request("SLOW",D.minusDays(30),D,null,null,utc(D,8),0,20)); 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()); assertEquals(D.minusDays(60),second.get(0).lastOutbound());
} }
@SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){ @SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null)); return (List<AnalyticsReadMapper.RankingStat>)(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));} private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private List<AnalyticsReadRows.Inventory> inventory(String attention,Integer threshold,String sort,long offset,int size){ private List<AnalyticsReadRows.Inventory> inventory(String attention,Integer threshold,String sort,long offset,int size){
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold)); return (List<AnalyticsReadRows.Inventory>)(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") @SuppressWarnings("unchecked")
private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
@@ -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);}
}
@@ -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());
}
}
@@ -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<String,Object> stats(){Map<String,Object> result=new HashMap<>();((Map<String,Object>)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<DashboardReadMapper.Ranked> ranks(){return (List<DashboardReadMapper.Ranked>)(List<?>)query(DashboardReadMapper.class,"rankings",Map.of("p",request(),"o",options()));}
private static long count(Map<String,Object> stats,String key){return ((Number)stats.get(key)).longValue();}
private List<?> query(Class<?> mapper,String method,Map<String,Object> 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<bound.getSql().length();i++){char ch=bound.getSql().charAt(i);if(ch=='\'')quoted=!quoted;
if(ch!='?'||quoted){sql.append(ch);continue;}String key=bound.getParameterMappings().get(index++).getProperty();Object v=meta.getValue(key);
if(v==null)sql.append("NULL");else if(v instanceof Boolean b)sql.append(b?1:0);else if(v instanceof Number)sql.append(v);else sql.append("'").append(v.toString().replace("'","''")).append("'");
}
String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,sql.toString()),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id);
}
}
@@ -127,6 +127,38 @@ class SelfopDiscoveryServiceTest {
assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus()))); assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus())));
assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount()); 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<SelfopOutboundOrder> 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<String> 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() { @Test void eachActualDetailGetsIndependentObservationNotTaskSequence() {
directoryTwoOrders(); service.collect(task, run, lease); directoryTwoOrders(); service.collect(task, run, lease);
assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList())); assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList()));