S8OutOfRangeRuleEvaluator.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. using System.Data;
  2. using System.Globalization;
  3. using System.Text.Json;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Microsoft.Extensions.Logging;
  7. using SqlSugar;
  8. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  9. /// <summary>
  10. /// R3 OUT_OF_RANGE 类规则 evaluator MVP("测量值越界")。
  11. /// 三种最小模式:固定上限 / 固定下限 / 行内上下限;兼容 G01 single_threshold 协议。
  12. /// 判定(任一命中即 HIT):
  13. /// measured &gt; upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| &gt; toleranceRatio → ABOVE_UPPER
  14. /// measured &lt; lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| &gt; toleranceRatio → BELOW_LOWER
  15. /// upperBound / lowerBound 为 0 时 ratio 检查跳过(避免除零)。
  16. /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
  17. /// </summary>
  18. public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
  19. {
  20. public const string RuleTypeCode = "OUT_OF_RANGE";
  21. public string RuleType => RuleTypeCode;
  22. private const string SqlDataSourceType = "SQL";
  23. private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
  24. // S8-WATCH-EXPRESSION-COLUMN-CONTRACT-SHORTAGE-OUTOFRANGE-FIX-1:S8ConfigDraftService.BuildExpression
  25. // 把 VALUE_RANGE 结果列统一别名为以下 canonical 名(无论源表真实列名为何)。evaluator 优先按 canonical
  26. // 读取,仅当结果集不含 canonical 列时才回退到 params_json 指定的真实列名(兼容历史/手工规则)。
  27. // 上下限为行内可选字段、无 canonical 契约,沿用 params 既有逻辑不变。
  28. private const string CanonicalMeasuredValueColumn = "measured_value";
  29. private const string CanonicalSourceObjectIdColumn = "source_object_id";
  30. private const string CanonicalRelatedObjectCodeColumn = "related_object_code";
  31. private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
  32. private readonly S8SqlSugarScopeFactory _scopeFactory;
  33. private readonly ILogger<S8OutOfRangeRuleEvaluator> _logger;
  34. public S8OutOfRangeRuleEvaluator(
  35. SqlSugarRepository<AdoS8DataSource> dataSourceRep,
  36. S8SqlSugarScopeFactory scopeFactory,
  37. ILogger<S8OutOfRangeRuleEvaluator> logger)
  38. {
  39. _dataSourceRep = dataSourceRep;
  40. _scopeFactory = scopeFactory;
  41. _logger = logger;
  42. }
  43. public async Task<List<S8RuleHit>> EvaluateAsync(
  44. long tenantId,
  45. long factoryId,
  46. AdoS8WatchRule rule,
  47. IReadOnlyList<AdoS8AlertRule> alertRules,
  48. CancellationToken cancellationToken = default)
  49. {
  50. var hits = new List<S8RuleHit>();
  51. // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
  52. if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
  53. throw new S8RuleEvaluatorException("rule_not_configured", $"OUT_OF_RANGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
  54. S8OutOfRangeParams parameters;
  55. try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
  56. catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
  57. if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
  58. throw new S8RuleEvaluatorException("params_schema_invalid", $"OUT_OF_RANGE 规则 {rule.RuleCode} params 缺少必填字段 measuredValueField");
  59. var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
  60. ? DefaultExceptionTypeCode
  61. : parameters.ExceptionTypeCode!;
  62. var dataSource = await _dataSourceRep.AsQueryable()
  63. .Where(x => x.Id == rule.DataSourceId
  64. && x.TenantId == tenantId
  65. && x.FactoryId == factoryId
  66. && x.Enabled)
  67. .FirstAsync();
  68. if (dataSource == null
  69. || string.IsNullOrWhiteSpace(dataSource.Endpoint)
  70. || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
  71. throw new S8RuleEvaluatorException("data_source_unavailable", $"OUT_OF_RANGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
  72. // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
  73. var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
  74. var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
  75. DataTable table;
  76. try
  77. {
  78. using var db = _scopeFactory.CreateScope(dataSource.Endpoint!, _dataSourceRep.Context.CurrentConnectionConfig.DbType, timeoutSeconds);
  79. table = await db.Ado.GetDataTableAsync(rule.Expression!);
  80. }
  81. catch (Exception ex)
  82. {
  83. throw new S8RuleEvaluatorException("query_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
  84. }
  85. // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。
  86. // 必须置于 try-catch 之外,避免被 query_failed 误捕获再包装。
  87. S8EvaluatorGuard.EnsureRowCountWithinLimit(table.Rows.Count, maxRows, RuleTypeCode, rule.RuleCode);
  88. var detectedAt = DateTime.Now;
  89. var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
  90. ? rule.WatchObjectType
  91. : rule.SourceObjectType!;
  92. // 结果列名一次性解析(结果集列在整张 DataTable 内稳定):canonical 优先,缺失回退 params 真实列名。
  93. var measuredValueColumn = ResolveResultColumn(table, CanonicalMeasuredValueColumn, parameters.MeasuredValueField);
  94. var objectCodeColumn = ResolveResultColumn(table, CanonicalRelatedObjectCodeColumn, parameters.ObjectCodeField);
  95. var objectIdColumn = ResolveResultColumn(table, CanonicalSourceObjectIdColumn, parameters.ObjectIdField);
  96. foreach (DataRow row in table.Rows)
  97. {
  98. var measured = ReadDecimal(row, measuredValueColumn);
  99. if (measured == null) continue;
  100. // 行内上下限优先;缺失时回退到固定上下限。
  101. decimal? lower = !string.IsNullOrWhiteSpace(parameters.LowerBoundField)
  102. ? ReadDecimal(row, parameters.LowerBoundField!) ?? parameters.LowerBound
  103. : parameters.LowerBound;
  104. decimal? upper = !string.IsNullOrWhiteSpace(parameters.UpperBoundField)
  105. ? ReadDecimal(row, parameters.UpperBoundField!) ?? parameters.UpperBound
  106. : parameters.UpperBound;
  107. if (lower == null && upper == null) continue; // 无界不命中
  108. string? direction = null;
  109. decimal deviation = 0m;
  110. if (upper != null && measured.Value > upper.Value + parameters.ToleranceAbs)
  111. {
  112. var dev = measured.Value - upper.Value;
  113. if (upper.Value == 0m || dev / Math.Abs(upper.Value) > parameters.ToleranceRatio)
  114. {
  115. direction = "ABOVE_UPPER";
  116. deviation = dev;
  117. }
  118. }
  119. if (direction == null && lower != null && measured.Value < lower.Value - parameters.ToleranceAbs)
  120. {
  121. var dev = lower.Value - measured.Value;
  122. if (lower.Value == 0m || dev / Math.Abs(lower.Value) > parameters.ToleranceRatio)
  123. {
  124. direction = "BELOW_LOWER";
  125. deviation = dev;
  126. }
  127. }
  128. if (direction == null) continue;
  129. var relatedObjectCode = ReadString(row, objectCodeColumn) ?? string.Empty;
  130. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  131. var sourceObjectId = ReadString(row, objectIdColumn) ?? relatedObjectCode;
  132. var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
  133. hits.Add(new S8RuleHit
  134. {
  135. SourceRuleId = rule.Id,
  136. SourceRuleCode = rule.RuleCode,
  137. SourceObjectType = sourceObjectType,
  138. SourceObjectId = sourceObjectId,
  139. RelatedObjectCode = relatedObjectCode,
  140. ExceptionTypeCode = exceptionTypeCode,
  141. SceneCode = rule.SceneCode,
  142. Severity = S8SeverityCode.Normalize(rule.Severity),
  143. DedupKey = dedupKey,
  144. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
  145. DetectedAt = detectedAt,
  146. Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
  147. DataSourceId = dataSource.Id,
  148. OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"),
  149. ResponsibleDeptId = ReadLong(row, "responsible_dept_id")
  150. });
  151. }
  152. return hits;
  153. }
  154. /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
  155. internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  156. $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  157. private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
  158. {
  159. if (direction == "ABOVE_UPPER")
  160. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
  161. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
  162. }
  163. private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId,
  164. decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
  165. {
  166. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  167. foreach (DataColumn column in row.Table.Columns)
  168. {
  169. var value = row[column];
  170. payload[column.ColumnName] = value == DBNull.Value ? null : value;
  171. }
  172. payload["measured_value"] = measured;
  173. payload["lower_bound"] = lower;
  174. payload["upper_bound"] = upper;
  175. payload["deviation"] = deviation;
  176. payload["direction"] = direction;
  177. payload["__ruleType"] = RuleTypeCode;
  178. payload["__sourceObjectType"] = sourceObjectType;
  179. payload["__sourceObjectId"] = sourceObjectId;
  180. payload["__exceptionTypeCode"] = exceptionTypeCode;
  181. return JsonSerializer.Serialize(payload);
  182. }
  183. /// <summary>
  184. /// 结果列名解析:BuildExpression 已把 VALUE_RANGE 结果列统一别名为 canonical(measured_value/
  185. /// source_object_id/related_object_code)。优先返回 canonical 列名;仅当结果集不含 canonical 列时,
  186. /// 回退到 params_json 指定的真实列名(兼容历史/手工规则)。仅在 canonical 与 params 字段之间二选一,
  187. /// 不新增无依据兜底字段。
  188. /// </summary>
  189. private static string ResolveResultColumn(DataTable table, string canonicalColumn, string? paramsColumn)
  190. {
  191. if (table.Columns.Contains(canonicalColumn)) return canonicalColumn;
  192. return string.IsNullOrWhiteSpace(paramsColumn) ? canonicalColumn : paramsColumn!;
  193. }
  194. private static string? ReadString(DataRow row, string columnName)
  195. {
  196. if (string.IsNullOrWhiteSpace(columnName)) return null;
  197. if (!row.Table.Columns.Contains(columnName)) return null;
  198. var v = row[columnName];
  199. return v == DBNull.Value ? null : Convert.ToString(v)?.Trim();
  200. }
  201. private static decimal? ReadDecimal(DataRow row, string columnName)
  202. {
  203. if (string.IsNullOrWhiteSpace(columnName)) return null;
  204. if (!row.Table.Columns.Contains(columnName)) return null;
  205. var v = row[columnName];
  206. if (v == DBNull.Value) return null;
  207. return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var r) ? r : null;
  208. }
  209. private static long? ReadLong(DataRow row, string columnName)
  210. {
  211. if (!row.Table.Columns.Contains(columnName)) return null;
  212. var v = row[columnName];
  213. if (v == DBNull.Value) return null;
  214. return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
  215. }
  216. }