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