S8OutOfRangeRuleEvaluator.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  5. using Microsoft.Extensions.Logging;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  7. /// <summary>
  8. /// R3 OUT_OF_RANGE 类规则 evaluator MVP("测量值越界")。
  9. /// 三种最小模式:固定上限 / 固定下限 / 行内上下限;兼容 G01 single_threshold 协议。
  10. /// 判定(任一命中即 HIT):
  11. /// measured &gt; upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| &gt; toleranceRatio → ABOVE_UPPER
  12. /// measured &lt; lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| &gt; toleranceRatio → BELOW_LOWER
  13. /// upperBound / lowerBound 为 0 时 ratio 检查跳过(避免除零)。
  14. /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
  15. ///
  16. /// S8-DATASET-PROVIDER-FOUNDATION-1:取数改由 <see cref="S8MonitoringDataGateway"/> 承担,
  17. /// 判定算法与迁移前逐行一致。
  18. /// </summary>
  19. public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
  20. {
  21. public const string RuleTypeCode = "OUT_OF_RANGE";
  22. public string RuleType => RuleTypeCode;
  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 = S8CanonicalColumns.MeasuredValue;
  29. private const string CanonicalSourceObjectIdColumn = S8CanonicalColumns.SourceObjectId;
  30. private const string CanonicalRelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode;
  31. private readonly S8MonitoringDataGateway _dataGateway;
  32. private readonly ILogger<S8OutOfRangeRuleEvaluator> _logger;
  33. public S8OutOfRangeRuleEvaluator(
  34. S8MonitoringDataGateway dataGateway,
  35. ILogger<S8OutOfRangeRuleEvaluator> logger)
  36. {
  37. _dataGateway = dataGateway;
  38. _logger = logger;
  39. }
  40. public async Task<List<S8RuleHit>> EvaluateAsync(
  41. long tenantId,
  42. long factoryId,
  43. AdoS8WatchRule rule,
  44. IReadOnlyList<AdoS8AlertRule> alertRules,
  45. CancellationToken cancellationToken = default)
  46. {
  47. // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
  48. var accessMode = S8DataAccessMode.Resolve(rule.DataAccessMode);
  49. var expressionRequired = accessMode == S8DataAccessMode.LegacySql;
  50. if ((expressionRequired && string.IsNullOrWhiteSpace(rule.Expression))
  51. || string.IsNullOrWhiteSpace(rule.ParamsJson))
  52. throw new S8RuleEvaluatorException("rule_not_configured", $"OUT_OF_RANGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
  53. S8OutOfRangeParams parameters;
  54. try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
  55. catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
  56. if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
  57. throw new S8RuleEvaluatorException("params_schema_invalid", $"OUT_OF_RANGE 规则 {rule.RuleCode} params 缺少必填字段 measuredValueField");
  58. // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
  59. var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
  60. var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
  61. var data = await _dataGateway.LoadAsync(
  62. tenantId, factoryId, rule, RuleTypeCode, timeoutSeconds, maxRows, cancellationToken);
  63. return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, data.DataSourceId, DateTime.Now);
  64. }
  65. /// <summary>
  66. /// OUT_OF_RANGE 判定核心:只消费 canonical 行,不接触 SQL / DataTable / 数据源。
  67. /// 判定算法与迁移前逐行一致;internal 暴露供契约测试直接驱动。
  68. /// </summary>
  69. internal static List<S8RuleHit> EvaluateRows(
  70. S8MonitoringRowSet rowSet,
  71. S8OutOfRangeParams parameters,
  72. AdoS8WatchRule rule,
  73. long tenantId,
  74. long factoryId,
  75. long dataSourceId,
  76. DateTime detectedAt)
  77. {
  78. var hits = new List<S8RuleHit>();
  79. var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
  80. ? DefaultExceptionTypeCode
  81. : parameters.ExceptionTypeCode!;
  82. var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
  83. ? rule.WatchObjectType
  84. : rule.SourceObjectType!;
  85. // 结果列名一次性解析(结果集列在整个结果集内稳定):canonical 优先,缺失回退 params 真实列名。
  86. var measuredValueColumn = ResolveResultColumn(rowSet, CanonicalMeasuredValueColumn, parameters.MeasuredValueField);
  87. var objectCodeColumn = ResolveResultColumn(rowSet, CanonicalRelatedObjectCodeColumn, parameters.ObjectCodeField);
  88. var objectIdColumn = ResolveResultColumn(rowSet, CanonicalSourceObjectIdColumn, parameters.ObjectIdField);
  89. foreach (var row in rowSet.Rows)
  90. {
  91. var measured = row.GetDecimal(measuredValueColumn);
  92. if (measured == null) continue;
  93. // 行内上下限优先;缺失时回退到固定上下限。
  94. decimal? lower = !string.IsNullOrWhiteSpace(parameters.LowerBoundField)
  95. ? row.GetDecimal(parameters.LowerBoundField!) ?? parameters.LowerBound
  96. : parameters.LowerBound;
  97. decimal? upper = !string.IsNullOrWhiteSpace(parameters.UpperBoundField)
  98. ? row.GetDecimal(parameters.UpperBoundField!) ?? parameters.UpperBound
  99. : parameters.UpperBound;
  100. if (lower == null && upper == null) continue; // 无界不命中
  101. string? direction = null;
  102. decimal deviation = 0m;
  103. if (upper != null && measured.Value > upper.Value + parameters.ToleranceAbs)
  104. {
  105. var dev = measured.Value - upper.Value;
  106. if (upper.Value == 0m || dev / Math.Abs(upper.Value) > parameters.ToleranceRatio)
  107. {
  108. direction = "ABOVE_UPPER";
  109. deviation = dev;
  110. }
  111. }
  112. if (direction == null && lower != null && measured.Value < lower.Value - parameters.ToleranceAbs)
  113. {
  114. var dev = lower.Value - measured.Value;
  115. if (lower.Value == 0m || dev / Math.Abs(lower.Value) > parameters.ToleranceRatio)
  116. {
  117. direction = "BELOW_LOWER";
  118. deviation = dev;
  119. }
  120. }
  121. if (direction == null) continue;
  122. var relatedObjectCode = row.GetString(objectCodeColumn) ?? string.Empty;
  123. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  124. var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
  125. var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
  126. hits.Add(new S8RuleHit
  127. {
  128. SourceRuleId = rule.Id,
  129. SourceRuleCode = rule.RuleCode,
  130. SourceObjectType = sourceObjectType,
  131. SourceObjectId = sourceObjectId,
  132. RelatedObjectCode = relatedObjectCode,
  133. ExceptionTypeCode = exceptionTypeCode,
  134. SceneCode = rule.SceneCode,
  135. Severity = S8SeverityCode.Normalize(rule.Severity),
  136. DedupKey = dedupKey,
  137. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
  138. DetectedAt = detectedAt,
  139. Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
  140. DataSourceId = dataSourceId,
  141. OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
  142. ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
  143. });
  144. }
  145. return hits;
  146. }
  147. /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
  148. internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  149. $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  150. private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
  151. {
  152. if (direction == "ABOVE_UPPER")
  153. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
  154. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
  155. }
  156. private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
  157. decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
  158. {
  159. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  160. foreach (var kv in row.Values)
  161. payload[kv.Key] = kv.Value;
  162. payload["measured_value"] = measured;
  163. payload["lower_bound"] = lower;
  164. payload["upper_bound"] = upper;
  165. payload["deviation"] = deviation;
  166. payload["direction"] = direction;
  167. payload["__ruleType"] = RuleTypeCode;
  168. payload["__sourceObjectType"] = sourceObjectType;
  169. payload["__sourceObjectId"] = sourceObjectId;
  170. payload["__exceptionTypeCode"] = exceptionTypeCode;
  171. return JsonSerializer.Serialize(payload);
  172. }
  173. /// <summary>
  174. /// 结果列名解析:BuildExpression 已把 VALUE_RANGE 结果列统一别名为 canonical(measured_value/
  175. /// source_object_id/related_object_code)。优先返回 canonical 列名;仅当结果集不含 canonical 列时,
  176. /// 回退到 params_json 指定的真实列名(兼容历史/手工规则)。仅在 canonical 与 params 字段之间二选一,
  177. /// 不新增无依据兜底字段。
  178. /// </summary>
  179. private static string ResolveResultColumn(S8MonitoringRowSet rowSet, string canonicalColumn, string? paramsColumn)
  180. {
  181. if (rowSet.HasColumn(canonicalColumn)) return canonicalColumn;
  182. return string.IsNullOrWhiteSpace(paramsColumn) ? canonicalColumn : paramsColumn!;
  183. }
  184. }