AdoSmartOpsKpiDimensionConfigService.cs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
  3. using Admin.NET.Plugin.AiDOP.Entity;
  4. using SqlSugar;
  5. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  6. /// <summary>
  7. /// KPI 维度计算配置服务:CRUD + 校验 + 试算 + 发布/停用/激活(发布状态机,单生效版本事务保证)。
  8. /// 与当前生效汇总配置版本绑定;租户显式控制(ClearFilter),落在运行时 KPI 数据租户。
  9. /// SqlScript 属高危配置,接口须登录鉴权(Controller 层)。绝不改动 SUMMARY_SQL。
  10. /// </summary>
  11. public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
  12. {
  13. private const string StatusDraft = "DRAFT";
  14. private const string StatusPublished = "PUBLISHED";
  15. private const string StatusRetired = "RETIRED";
  16. private static readonly string[] AggregationTypes =
  17. { "DIRECT_VALUE", "RATIO_OF_SUMS", "AVERAGE_OF_SUMS", "SUMMARY_ONLY" };
  18. private readonly ISqlSugarClient _db;
  19. private readonly KpiDimensionSqlExecutor _executor;
  20. private readonly AdoSmartOpsKpiCalcConfigService _summaryConfig;
  21. public AdoSmartOpsKpiDimensionConfigService(
  22. ISqlSugarClient db, KpiDimensionSqlExecutor executor, AdoSmartOpsKpiCalcConfigService summaryConfig)
  23. {
  24. _db = db;
  25. _executor = executor;
  26. _summaryConfig = summaryConfig;
  27. }
  28. private ISugarQueryable<AdoSmartOpsKpiDimensionConfig> Query() =>
  29. _db.Queryable<AdoSmartOpsKpiDimensionConfig>().ClearFilter<ITenantIdFilter>();
  30. /// <summary>
  31. /// 写路径一律走原生参数化 SQL(表名与实体 SugarTable 一致,列名 PascalCase:EnableUnderLine=false)。
  32. /// 原因:全局 MoreSettings.IsAutoUpdateQueryFilter / IsAutoDeleteQueryFilter=true 会给 ORM 的 UPDATE/DELETE
  33. /// 自动追加「登录 JWT 租户」条件,而本表按 ResolveKpiTenantId 落在 KPI 数据租户,
  34. /// 两者不一致时静默 0 行(同 UAT-S0-07 / UAT-S9-01 缺陷);SqlSugar 5.1.4 的 IUpdateable/IDeleteable
  35. /// 又无法关闭该过滤器(EnableQueryFilter 只能开不能关,且 ClearFilter 仅存在于 ISugarQueryable)。
  36. /// 每条写语句都必须显式带 TenantId + 业务状态条件,**禁止按 Id 裸写**。
  37. /// </summary>
  38. private const string TableName = "ado_smart_ops_kpi_dimension_config";
  39. /// <summary>取配置归属租户;缺失即数据异常,直接拒绝,避免降级成按 Id 跨租户裸写。</summary>
  40. private static long RequireTenantId(AdoSmartOpsKpiDimensionConfig e) =>
  41. e.TenantId ?? throw Oops.Bah($"维度配置版本 Id={e.Id} 缺少 TenantId,数据异常,拒绝写入");
  42. /// <summary>
  43. /// 0 行写入归因:区分「目标不存在 / 租户不匹配 / 状态不满足 / 并发状态变化」,绝不静默返回成功。
  44. /// </summary>
  45. private async Task<Exception> ExplainZeroRowAsync(
  46. long id, long? expectedTenantId, string action, string statusRequirement, params string[] acceptableStatuses)
  47. {
  48. var now = await Query().Where(x => x.Id == id).FirstAsync();
  49. if (now == null)
  50. return Oops.Bah($"{action}失败:维度配置版本 Id={id} 不存在(可能已被并发删除)");
  51. if (now.TenantId != expectedTenantId)
  52. return Oops.Bah($"{action}失败:维度配置版本 Id={id} 归属租户 {now.TenantId},与本次作用域租户 {expectedTenantId} 不一致");
  53. if (acceptableStatuses.Length > 0 && !acceptableStatuses.Contains(now.PublishStatus))
  54. return Oops.Bah($"{action}失败:维度配置版本 Id={id} 当前状态为 {now.PublishStatus},不满足要求的 {statusRequirement}(并发修改,请刷新后重试)");
  55. return Oops.Bah($"{action}失败:维度配置版本 Id={id}(租户 {now.TenantId} / 状态 {now.PublishStatus})未命中任何行,疑似并发状态变化,请刷新后重试");
  56. }
  57. /// <summary>某 KPI 的全部维度配置版本(按版本号倒序)。</summary>
  58. public async Task<List<KpiDimensionConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
  59. {
  60. var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
  61. var list = await Query()
  62. .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
  63. .OrderBy(x => x.DimensionConfigVersion, OrderByType.Desc)
  64. .ToListAsync();
  65. return list.Select(ToDto).ToList();
  66. }
  67. /// <summary>当前生效维度配置(PUBLISHED + IsCurrent),无则 null。</summary>
  68. public async Task<AdoSmartOpsKpiDimensionConfig?> GetActiveAsync(long tenantId, string metricCode)
  69. {
  70. return await Query()
  71. .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId
  72. && x.IsCurrent && x.PublishStatus == StatusPublished)
  73. .FirstAsync();
  74. }
  75. /// <summary>新增草稿:绑定当前生效汇总配置版本;DimensionConfigVersion = 该汇总版本下现有最大 + 1。</summary>
  76. public async Task<KpiDimensionConfigDto> CreateDraftAsync(KpiDimensionConfigUpsertDto dto, string operatorName)
  77. {
  78. ValidateAggregation(dto.AggregationType);
  79. var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(dto.ModuleCode);
  80. var summary = await _summaryConfig.GetActiveAsync(tenantId, dto.MetricCode)
  81. ?? throw Oops.Bah("该 KPI 当前无生效的汇总配置(CONFIG_SQL),请先发布并激活汇总配置后再配置维度");
  82. var maxVer = await Query()
  83. .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId && x.SummaryConfigId == summary.Id)
  84. .MaxAsync(x => (int?)x.DimensionConfigVersion) ?? 0;
  85. var entity = new AdoSmartOpsKpiDimensionConfig
  86. {
  87. TenantId = tenantId,
  88. MetricCode = dto.MetricCode,
  89. ModuleCode = dto.ModuleCode,
  90. SummaryConfigId = summary.Id,
  91. SummaryConfigVersion = summary.VersionNo,
  92. DimensionConfigVersion = maxVer + 1,
  93. DataSourceCode = dto.DataSourceCode,
  94. SqlScript = dto.SqlScript,
  95. AggregationType = dto.AggregationType,
  96. SupportedDimensionsJson = dto.SupportedDimensionsJson,
  97. OutputContractJson = dto.OutputContractJson,
  98. ParameterContractJson = dto.ParameterContractJson,
  99. TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds),
  100. PublishStatus = StatusDraft,
  101. IsCurrent = false,
  102. BusinessSqlSource = dto.BusinessSqlSource,
  103. ChangeRemark = dto.ChangeRemark,
  104. Remark = dto.Remark,
  105. CreatedBy = operatorName,
  106. CreatedAt = DateTime.Now,
  107. };
  108. entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  109. return ToDto(entity);
  110. }
  111. /// <summary>编辑草稿(仅 DRAFT)。</summary>
  112. public async Task UpdateDraftAsync(long id, KpiDimensionConfigUpsertDto dto, string operatorName)
  113. {
  114. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  115. ?? throw Oops.Bah("维度配置版本不存在");
  116. if (entity.PublishStatus != StatusDraft)
  117. throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
  118. ValidateAggregation(dto.AggregationType);
  119. var tenantId = RequireTenantId(entity);
  120. var affected = await _db.Ado.ExecuteCommandAsync(
  121. $@"UPDATE {TableName}
  122. SET DataSourceCode=@ds, SqlScript=@sql, AggregationType=@agg,
  123. SupportedDimensionsJson=@dims, OutputContractJson=@output, ParameterContractJson=@paramContract,
  124. TimeoutSeconds=@timeout, BusinessSqlSource=@bizSrc, ChangeRemark=@chg, Remark=@remark,
  125. UpdatedBy=@op, UpdatedAt=@now
  126. WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@draft",
  127. new SugarParameter("@ds", dto.DataSourceCode),
  128. new SugarParameter("@sql", dto.SqlScript),
  129. new SugarParameter("@agg", dto.AggregationType),
  130. new SugarParameter("@dims", dto.SupportedDimensionsJson),
  131. new SugarParameter("@output", dto.OutputContractJson),
  132. new SugarParameter("@paramContract", dto.ParameterContractJson),
  133. new SugarParameter("@timeout", NormalizeTimeout(dto.TimeoutSeconds)),
  134. new SugarParameter("@bizSrc", dto.BusinessSqlSource),
  135. new SugarParameter("@chg", dto.ChangeRemark),
  136. new SugarParameter("@remark", dto.Remark),
  137. new SugarParameter("@op", operatorName),
  138. new SugarParameter("@now", DateTime.Now),
  139. new SugarParameter("@id", id),
  140. new SugarParameter("@tenant", tenantId),
  141. new SugarParameter("@draft", StatusDraft));
  142. if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "编辑草稿", "DRAFT", StatusDraft);
  143. }
  144. /// <summary>删除草稿(仅 DRAFT)。</summary>
  145. public async Task DeleteAsync(long id)
  146. {
  147. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  148. ?? throw Oops.Bah("维度配置版本不存在");
  149. if (entity.PublishStatus != StatusDraft)
  150. throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
  151. var tenantId = RequireTenantId(entity);
  152. var affected = await _db.Ado.ExecuteCommandAsync(
  153. $"DELETE FROM {TableName} WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@draft",
  154. new SugarParameter("@id", id),
  155. new SugarParameter("@tenant", tenantId),
  156. new SugarParameter("@draft", StatusDraft));
  157. if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "删除草稿", "DRAFT", StatusDraft);
  158. }
  159. /// <summary>SQL 安全校验(复用汇总配置的多层非正则校验器)。</summary>
  160. public KpiSqlValidateResultDto Validate(string? sql)
  161. {
  162. var r = KpiSqlSecurityValidator.Validate(sql);
  163. return new KpiSqlValidateResultDto
  164. {
  165. Ok = r.Ok,
  166. ValidationStatus = r.Ok ? "VALID" : "INVALID",
  167. ErrorCode = r.ErrorCode,
  168. ErrorMessage = r.ErrorMessage,
  169. ReferencedTables = r.ReferencedTables,
  170. };
  171. }
  172. /// <summary>试算(服务端重校验 + 只读多行执行;不写维度结果)。返回前 20 行样本 + 输出列 + 可识别维度。</summary>
  173. public async Task<KpiDimensionPreviewResultDto> PreviewAsync(KpiDimensionPreviewDto dto, CancellationToken ct)
  174. {
  175. var bizDate = (dto.BizDate ?? DateTime.Today.AddDays(-1)).Date;
  176. var res = new KpiDimensionPreviewResultDto
  177. {
  178. MetricCode = dto.MetricCode,
  179. DataSourceCode = dto.DataSourceCode,
  180. BizDate = bizDate.ToString("yyyy-MM-dd"),
  181. };
  182. var val = KpiSqlSecurityValidator.Validate(dto.SqlScript);
  183. res.ValidationStatus = val.Ok ? "VALID" : "INVALID";
  184. if (!val.Ok)
  185. {
  186. res.Ok = false;
  187. res.ResultStatus = "FAILED";
  188. res.ErrorCode = val.ErrorCode;
  189. res.ErrorMessage = val.ErrorMessage;
  190. return res;
  191. }
  192. var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(dto.ModuleCode);
  193. var pars = BuildRunParams(tenantId, dto.ModuleCode, dto.MetricCode, bizDate);
  194. var exec = await _executor.ExecuteAsync(dto.DataSourceCode, dto.SqlScript ?? "", dto.TimeoutSeconds, true, pars, ct);
  195. res.Ok = exec.Status != "FAILED";
  196. res.ResultStatus = exec.Status;
  197. res.DurationMs = exec.DurationMs;
  198. res.RowCount = exec.RowCount;
  199. res.OutputColumns = exec.OutputColumns;
  200. res.SupportedDimensions = DetectSupportedDimensions(exec.OutputColumns);
  201. res.ErrorCode = exec.ErrorCode;
  202. res.ErrorMessage = exec.ErrorMessage;
  203. res.SampleRows = exec.Rows.Take(20).Select(ToSampleRow).ToList();
  204. res.ExecutedParameters =
  205. $"tenant_id={pars.TenantId}, factory_id={pars.FactoryId}, module_code={pars.ModuleCode}, " +
  206. $"metric_code={pars.MetricCode}, biz_date={pars.BizDate:yyyy-MM-dd}, ztid={pars.SourceZtid}";
  207. return res;
  208. }
  209. /// <summary>发布:绑定汇总版本一致性校验 + 安全校验 + 事务单生效版本。</summary>
  210. public async Task PublishAsync(long id, string operatorName)
  211. {
  212. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  213. ?? throw Oops.Bah("维度配置版本不存在");
  214. if (entity.PublishStatus == StatusRetired)
  215. throw Oops.Bah("已停用版本不可发布,请新建版本");
  216. ValidateAggregation(entity.AggregationType);
  217. if (entity.AggregationType != "SUMMARY_ONLY")
  218. {
  219. var val = KpiSqlSecurityValidator.Validate(entity.SqlScript);
  220. if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
  221. if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
  222. throw Oops.Bah("非 SUMMARY_ONLY 的维度配置必须指定数据源");
  223. if (string.IsNullOrWhiteSpace(entity.SupportedDimensionsJson))
  224. throw Oops.Bah("必须声明支持维度(SupportedDimensions)");
  225. }
  226. // 绑定的汇总配置必须仍存在且为当前生效版本
  227. var summary = await _summaryConfig.GetActiveAsync(entity.TenantId ?? 0, entity.MetricCode)
  228. ?? throw Oops.Bah("绑定的汇总配置已不存在或未生效,请基于当前汇总版本新建维度配置");
  229. if (summary.Id != entity.SummaryConfigId || summary.VersionNo != entity.SummaryConfigVersion)
  230. throw Oops.Bah($"汇总配置已切换到新版本(v{summary.VersionNo}),本维度配置绑定的是 v{entity.SummaryConfigVersion},请基于当前汇总版本新建维度配置");
  231. var tenantId = RequireTenantId(entity);
  232. var tran = await _db.AsTenant().UseTranAsync(async () =>
  233. {
  234. // 旧 current 置 0:0 行是合法结果(本 KPI 此前无生效维度版本),故不做 affected 断言
  235. await _db.Ado.ExecuteCommandAsync(
  236. $@"UPDATE {TableName} SET IsCurrent=0
  237. WHERE MetricCode=@metric AND TenantId=@tenant AND Id<>@id AND IsCurrent=1",
  238. new SugarParameter("@metric", entity.MetricCode),
  239. new SugarParameter("@tenant", tenantId),
  240. new SugarParameter("@id", id));
  241. var affected = await _db.Ado.ExecuteCommandAsync(
  242. $@"UPDATE {TableName}
  243. SET PublishStatus=@published, IsCurrent=1, PublishedBy=@op, PublishedAt=@now
  244. WHERE Id=@id AND TenantId=@tenant AND PublishStatus<>@retired",
  245. new SugarParameter("@published", StatusPublished),
  246. new SugarParameter("@op", operatorName),
  247. new SugarParameter("@now", DateTime.Now),
  248. new SugarParameter("@id", id),
  249. new SugarParameter("@tenant", tenantId),
  250. new SugarParameter("@retired", StatusRetired));
  251. if (affected <= 0)
  252. throw await ExplainZeroRowAsync(id, tenantId, "发布", "非 RETIRED", StatusDraft, StatusPublished);
  253. });
  254. if (!tran.IsSuccess) throw tran.ErrorException;
  255. }
  256. /// <summary>激活/回滚:把某已发布维度版本设为当前生效。</summary>
  257. public async Task ActivateAsync(long id, string operatorName)
  258. {
  259. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  260. ?? throw Oops.Bah("维度配置版本不存在");
  261. if (entity.PublishStatus != StatusPublished)
  262. throw Oops.Bah("只能激活已发布(PUBLISHED)版本");
  263. var tenantId = RequireTenantId(entity);
  264. var tran = await _db.AsTenant().UseTranAsync(async () =>
  265. {
  266. // 旧 current 置 0:0 行是合法结果(本 KPI 此前无生效维度版本),故不做 affected 断言
  267. await _db.Ado.ExecuteCommandAsync(
  268. $@"UPDATE {TableName} SET IsCurrent=0
  269. WHERE MetricCode=@metric AND TenantId=@tenant AND Id<>@id AND IsCurrent=1",
  270. new SugarParameter("@metric", entity.MetricCode),
  271. new SugarParameter("@tenant", tenantId),
  272. new SugarParameter("@id", id));
  273. var affected = await _db.Ado.ExecuteCommandAsync(
  274. $@"UPDATE {TableName} SET IsCurrent=1, UpdatedBy=@op, UpdatedAt=@now
  275. WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@published",
  276. new SugarParameter("@op", operatorName),
  277. new SugarParameter("@now", DateTime.Now),
  278. new SugarParameter("@id", id),
  279. new SugarParameter("@tenant", tenantId),
  280. new SugarParameter("@published", StatusPublished));
  281. if (affected <= 0)
  282. throw await ExplainZeroRowAsync(id, tenantId, "激活", "PUBLISHED", StatusPublished);
  283. });
  284. if (!tran.IsSuccess) throw tran.ErrorException;
  285. }
  286. /// <summary>停用(RETIRED,IsCurrent=0)。停用当前生效版本后无 current → 筛选/下钻显示未配置。</summary>
  287. public async Task RetireAsync(long id, string operatorName)
  288. {
  289. var entity = await Query().Where(x => x.Id == id).FirstAsync()
  290. ?? throw Oops.Bah("维度配置版本不存在");
  291. var tenantId = RequireTenantId(entity);
  292. var affected = await _db.Ado.ExecuteCommandAsync(
  293. $@"UPDATE {TableName}
  294. SET PublishStatus=@retired, IsCurrent=0, RetiredBy=@op, RetiredAt=@now
  295. WHERE Id=@id AND TenantId=@tenant",
  296. new SugarParameter("@retired", StatusRetired),
  297. new SugarParameter("@op", operatorName),
  298. new SugarParameter("@now", DateTime.Now),
  299. new SugarParameter("@id", id),
  300. new SugarParameter("@tenant", tenantId));
  301. if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "停用", "任意状态");
  302. }
  303. /// <summary>KPI 维度能力:无生效维度配置 → PENDING_DIMENSION_SQL;SUMMARY_ONLY → SUMMARY_ONLY;否则 CONFIGURED。</summary>
  304. public async Task<KpiDimensionCapabilityDto> GetCapabilityAsync(string metricCode, string moduleCode)
  305. {
  306. var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
  307. var active = await GetActiveAsync(tenantId, metricCode);
  308. var cap = new KpiDimensionCapabilityDto { MetricCode = metricCode, ModuleCode = moduleCode };
  309. if (active == null)
  310. {
  311. cap.Supported = false;
  312. cap.CapabilityStatus = "PENDING_DIMENSION_SQL";
  313. return cap;
  314. }
  315. // 维度绑定的汇总版本(兼容旧字段 + 明确的 Bound* 字段)
  316. cap.SummaryConfigVersion = active.SummaryConfigVersion;
  317. cap.BoundSummaryConfigId = active.SummaryConfigId;
  318. cap.BoundSummaryConfigVersion = active.SummaryConfigVersion;
  319. cap.DimensionConfigVersion = active.DimensionConfigVersion;
  320. cap.AggregationType = active.AggregationType;
  321. cap.SupportedDimensions = ParseDimensions(active.SupportedDimensionsJson);
  322. // 当前生效的汇总版本(可能已推进超过维度绑定);一致性同时比对 Id 与 Version
  323. var currentSummary = await _summaryConfig.GetActiveAsync(tenantId, metricCode);
  324. cap.CurrentSummaryConfigId = currentSummary?.Id;
  325. cap.CurrentSummaryConfigVersion = currentSummary?.VersionNo;
  326. cap.IsSummaryBindingConsistent = currentSummary != null
  327. && currentSummary.Id == active.SummaryConfigId
  328. && currentSummary.VersionNo == active.SummaryConfigVersion;
  329. if (active.AggregationType == "SUMMARY_ONLY")
  330. {
  331. cap.Supported = false;
  332. cap.CapabilityStatus = "SUMMARY_ONLY";
  333. }
  334. else if (!cap.IsSummaryBindingConsistent)
  335. {
  336. // 汇总已推进 / 当前汇总缺失:旧绑定维度不可直接投用,避免误判可用
  337. cap.Supported = false;
  338. cap.CapabilityStatus = "VERSION_MISMATCH";
  339. }
  340. else
  341. {
  342. cap.Supported = true;
  343. cap.CapabilityStatus = "CONFIGURED";
  344. }
  345. var lastRun = await _db.Queryable<AdoSmartOpsKpiDimensionRunLog>().ClearFilter<ITenantIdFilter>()
  346. .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
  347. .OrderBy(x => x.StartedAt, OrderByType.Desc)
  348. .FirstAsync();
  349. if (lastRun != null)
  350. {
  351. cap.LastRunStatus = lastRun.Status;
  352. cap.LastRunAt = lastRun.StartedAt;
  353. }
  354. return cap;
  355. }
  356. /// <summary>
  357. /// 维度明细只读查询(当前生效维度版本):按 AggregationType 复算聚合值 + 分页明细。
  358. /// 只读当前激活配置产生的维度结果,绝不读旧 Atomic。无激活配置 → NOT_CONFIGURED。
  359. /// </summary>
  360. public async Task<KpiDimensionDetailResultDto> QueryDetailAsync(
  361. string metricCode, string moduleCode, DateTime? startDate, DateTime? endDate,
  362. string? dimensionType, string? dimensionCode, int page, int pageSize,
  363. string? orderNo = null, string? sortField = null, string? sortOrder = null)
  364. {
  365. var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
  366. var active = await GetActiveAsync(tenantId, metricCode);
  367. var result = new KpiDimensionDetailResultDto { MetricCode = metricCode };
  368. if (active == null)
  369. {
  370. result.Status = "NOT_CONFIGURED";
  371. return result;
  372. }
  373. result.SummaryConfigVersion = active.SummaryConfigVersion;
  374. result.DimensionConfigVersion = active.DimensionConfigVersion;
  375. result.AggregationType = active.AggregationType;
  376. var sd = startDate?.Date;
  377. var ed = endDate?.Date;
  378. var ordNo = string.IsNullOrWhiteSpace(orderNo) ? null : orderNo!.Trim();
  379. ISugarQueryable<AdoSmartOpsKpiDimensionValueDay> Build() =>
  380. _db.Queryable<AdoSmartOpsKpiDimensionValueDay>().ClearFilter<ITenantIdFilter>()
  381. .Where(x => x.TenantId == tenantId && x.MetricCode == metricCode
  382. && x.DimensionConfigVersion == active.DimensionConfigVersion)
  383. .WhereIF(sd.HasValue, x => x.ValueDate >= sd!.Value)
  384. .WhereIF(ed.HasValue, x => x.ValueDate <= ed!.Value)
  385. .WhereIF(!string.IsNullOrWhiteSpace(dimensionType), x => x.DimensionType == dimensionType)
  386. .WhereIF(!string.IsNullOrWhiteSpace(dimensionCode), x => x.DimensionCode == dimensionCode)
  387. .WhereIF(ordNo != null, x => x.OrderNo == ordNo || x.DimensionCode == ordNo);
  388. result.AggregateValue = active.AggregationType switch
  389. {
  390. "AVERAGE_OF_SUMS" => Div(await Build().SumAsync(x => x.SumValue), (decimal?)await Build().SumAsync(x => x.SampleCount)),
  391. "RATIO_OF_SUMS" => Div(await Build().SumAsync(x => x.Numerator), await Build().SumAsync(x => x.Denominator)),
  392. "DIRECT_VALUE" => await Build().AvgAsync(x => x.MetricValue),
  393. _ => null,
  394. };
  395. // 排序字段白名单,禁止任意列注入
  396. var ot = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? OrderByType.Asc : OrderByType.Desc;
  397. var sorted = (sortField ?? "").Trim().ToLowerInvariant() switch
  398. {
  399. "metric_value" or "metricvalue" => Build().OrderBy(x => x.MetricValue, ot).OrderBy(x => x.DimensionCode),
  400. _ => Build().OrderBy(x => x.ValueDate, ot).OrderBy(x => x.DimensionCode),
  401. };
  402. RefAsync<int> total = 0;
  403. var list = await sorted
  404. .ToPageListAsync(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, total);
  405. result.Total = total.Value;
  406. result.List = list.Select(x => new KpiDimensionDetailRowDto
  407. {
  408. ValueDate = x.ValueDate.ToString("yyyy-MM-dd"),
  409. DimensionType = x.DimensionType,
  410. DimensionCode = x.DimensionCode,
  411. DimensionName = x.DimensionName,
  412. MetricValue = x.MetricValue,
  413. Numerator = x.Numerator,
  414. Denominator = x.Denominator,
  415. SumValue = x.SumValue,
  416. SampleCount = x.SampleCount,
  417. SourceKey = x.SourceKey,
  418. BatchId = x.BatchId,
  419. }).ToList();
  420. return result;
  421. }
  422. private static decimal? Div(decimal? a, decimal? b) =>
  423. b.HasValue && b.Value != 0 ? (a ?? 0m) / b.Value : null;
  424. /// <summary>已登记数据源列表(第一版仅本地中台库)。</summary>
  425. public List<object> ListDataSources() => new()
  426. {
  427. new { code = KpiDimensionSqlExecutor.LocalDataSourceCode, name = "本地中台库(aidopdev / mdp_std_* / dwd_*)", readOnly = true },
  428. };
  429. internal static KpiSqlRunParams BuildRunParams(long tenantId, string moduleCode, string metricCode, DateTime bizDate) => new()
  430. {
  431. TenantId = tenantId,
  432. FactoryId = 1,
  433. ModuleCode = moduleCode,
  434. MetricCode = metricCode,
  435. BizDate = bizDate,
  436. PeriodStart = bizDate,
  437. PeriodEnd = bizDate.AddDays(1).AddSeconds(-1),
  438. SourceZtid = "pbxfxp",
  439. };
  440. private static readonly string[] KnownDimensionColumns =
  441. {
  442. "value_date", "org_id", "factory_id", "material_code", "work_order_no", "category_code",
  443. "warehouse_code", "order_no", "customer_code", "product_code", "equipment_code",
  444. };
  445. private static List<string> DetectSupportedDimensions(List<string> outputColumns)
  446. {
  447. var set = new HashSet<string>(outputColumns, StringComparer.OrdinalIgnoreCase);
  448. return KnownDimensionColumns.Where(set.Contains).ToList();
  449. }
  450. private static List<string> ParseDimensions(string? json)
  451. {
  452. if (string.IsNullOrWhiteSpace(json)) return new();
  453. try { return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? new(); }
  454. catch { return new(); }
  455. }
  456. private static Dictionary<string, object?> ToSampleRow(KpiDimensionRow r) => new()
  457. {
  458. ["value_date"] = r.ValueDate == DateTime.MinValue ? null : r.ValueDate.ToString("yyyy-MM-dd"),
  459. ["dimension_type"] = r.DimensionType,
  460. ["dimension_code"] = r.DimensionCode,
  461. ["dimension_name"] = r.DimensionName,
  462. ["metric_value"] = r.MetricValue,
  463. ["numerator"] = r.Numerator,
  464. ["denominator"] = r.Denominator,
  465. ["sum_value"] = r.SumValue,
  466. ["sample_count"] = r.SampleCount,
  467. };
  468. private static void ValidateAggregation(string agg)
  469. {
  470. if (!AggregationTypes.Contains(agg))
  471. throw Oops.Bah($"非法聚合类型:{agg}(DIRECT_VALUE/RATIO_OF_SUMS/AVERAGE_OF_SUMS/SUMMARY_ONLY)");
  472. }
  473. private static int NormalizeTimeout(int t) =>
  474. t <= 0 ? 60 : Math.Min(t, KpiDimensionSqlExecutor.SystemMaxTimeoutSeconds);
  475. private static KpiDimensionConfigDto ToDto(AdoSmartOpsKpiDimensionConfig e) => new()
  476. {
  477. Id = e.Id, TenantId = e.TenantId, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
  478. SummaryConfigId = e.SummaryConfigId, SummaryConfigVersion = e.SummaryConfigVersion,
  479. DimensionConfigVersion = e.DimensionConfigVersion, DataSourceCode = e.DataSourceCode,
  480. SqlScript = e.SqlScript, AggregationType = e.AggregationType,
  481. SupportedDimensionsJson = e.SupportedDimensionsJson, OutputContractJson = e.OutputContractJson,
  482. ParameterContractJson = e.ParameterContractJson, TimeoutSeconds = e.TimeoutSeconds,
  483. PublishStatus = e.PublishStatus, IsCurrent = e.IsCurrent, BusinessSqlSource = e.BusinessSqlSource,
  484. ChangeRemark = e.ChangeRemark, Remark = e.Remark, CreatedBy = e.CreatedBy, CreatedAt = e.CreatedAt,
  485. PublishedBy = e.PublishedBy, PublishedAt = e.PublishedAt,
  486. };
  487. }