S8OutOfRangeRuleEvaluator.cs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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 Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
  6. using Microsoft.Extensions.Logging;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  8. /// <summary>
  9. /// OUT_OF_RANGE 类规则 evaluator("测量值越界")。
  10. /// 三种最小模式:固定上限 / 固定下限 / 行内上下限。
  11. ///
  12. /// <para><b>S8-RULE-GOVERNANCE-BATCH1</b>:上下限、容差、测量列、异常类型、去重身份
  13. /// 改由 <see cref="S8RuleDefinition"/> 供给,不再从 params_json 解析
  14. /// (G01 的 single_threshold 兼容协议随 params 解析一并退役 —— 它服务的是"用户在页面里
  15. /// 拼阈值"这个已经取消的能力)。当前仓内**没有任何 OUT_OF_RANGE 规则定义**,
  16. /// 因此本 evaluator 实际不会被执行,这正是期望行为。</para>
  17. /// 判定(任一命中即 HIT):
  18. /// measured &gt; upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| &gt; toleranceRatio → ABOVE_UPPER
  19. /// measured &lt; lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| &gt; toleranceRatio → BELOW_LOWER
  20. /// upperBound / lowerBound 为 0 时 ratio 检查跳过(避免除零)。
  21. /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
  22. ///
  23. /// S8-DATASET-PROVIDER-FOUNDATION-1:取数改由 <see cref="S8MonitoringDataGateway"/> 承担,
  24. /// 判定算法与迁移前逐行一致。
  25. /// </summary>
  26. public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
  27. {
  28. public const string RuleTypeCode = "OUT_OF_RANGE";
  29. public string RuleType => RuleTypeCode;
  30. private readonly S8MonitoringDataGateway _dataGateway;
  31. private readonly IS8RuleCatalog _ruleCatalog;
  32. private readonly ILogger<S8OutOfRangeRuleEvaluator> _logger;
  33. public S8OutOfRangeRuleEvaluator(
  34. S8MonitoringDataGateway dataGateway,
  35. IS8RuleCatalog ruleCatalog,
  36. ILogger<S8OutOfRangeRuleEvaluator> logger)
  37. {
  38. _dataGateway = dataGateway;
  39. _ruleCatalog = ruleCatalog;
  40. _logger = logger;
  41. }
  42. public async Task<List<S8RuleHit>> EvaluateAsync(
  43. long tenantId,
  44. long factoryId,
  45. AdoS8WatchRule rule,
  46. CancellationToken cancellationToken = default)
  47. {
  48. // S8-RULE-GOVERNANCE-BATCH1:没有代码定义的规则一律不执行(详见 S8TimeoutRuleEvaluator 同处注释)。
  49. var effective = ResolveEffective(rule);
  50. // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。
  51. var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
  52. var data = await _dataGateway.LoadAsync(
  53. tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
  54. return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
  55. }
  56. /// <summary>解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。</summary>
  57. private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule)
  58. {
  59. var definition = _ruleCatalog.TryGet(rule.RuleCode)
  60. ?? throw new S8RuleEvaluatorException(
  61. S8RuleCatalog.ReasonNotFound,
  62. $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行");
  63. if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal))
  64. throw new S8RuleEvaluatorException(
  65. "rule_type_mismatch",
  66. $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 OUT_OF_RANGE evaluator 执行");
  67. if (definition.OutOfRange == null)
  68. throw new S8RuleEvaluatorException(
  69. "rule_semantics_missing",
  70. $"规则 {rule.RuleCode} 的代码定义缺少 OUT_OF_RANGE 判定语义");
  71. return S8EffectiveRule.Resolve(rule, definition);
  72. }
  73. /// <summary>
  74. /// OUT_OF_RANGE 判定核心:只消费 canonical 行,不接触 SQL / DataTable / 数据源。
  75. /// 判定算法与迁移前逐行一致;internal 暴露供契约测试直接驱动。
  76. /// </summary>
  77. internal static List<S8RuleHit> EvaluateRows(
  78. S8MonitoringRowSet rowSet,
  79. S8EffectiveRule effective,
  80. long tenantId,
  81. long factoryId,
  82. DateTime detectedAt)
  83. {
  84. var definition = effective.Definition;
  85. var semantics = definition.OutOfRange!;
  86. var parameters = effective.Parameters;
  87. var rule = effective.Row;
  88. var hits = new List<S8RuleHit>();
  89. var exceptionTypeCode = definition.ExceptionTypeCode;
  90. var sourceObjectType = definition.SourceObjectType;
  91. var measuredValueColumn = semantics.MeasuredValueColumn;
  92. var objectCodeColumn = semantics.RelatedObjectCodeColumn;
  93. var objectIdColumn = semantics.SourceObjectIdColumn;
  94. foreach (var row in rowSet.Rows)
  95. {
  96. var measured = row.GetDecimal(measuredValueColumn);
  97. if (measured == null) continue;
  98. // 行内上下限优先;缺失时回退到固定上下限。
  99. decimal? lower = !string.IsNullOrWhiteSpace(semantics.LowerBoundColumn)
  100. ? row.GetDecimal(semantics.LowerBoundColumn!) ?? semantics.LowerBound
  101. : semantics.LowerBound;
  102. decimal? upper = !string.IsNullOrWhiteSpace(semantics.UpperBoundColumn)
  103. ? row.GetDecimal(semantics.UpperBoundColumn!) ?? semantics.UpperBound
  104. : semantics.UpperBound;
  105. if (lower == null && upper == null) continue; // 无界不命中
  106. string? direction = null;
  107. decimal deviation = 0m;
  108. if (upper != null && measured.Value > upper.Value + semantics.ToleranceAbs)
  109. {
  110. var dev = measured.Value - upper.Value;
  111. if (upper.Value == 0m || dev / Math.Abs(upper.Value) > semantics.ToleranceRatio)
  112. {
  113. direction = "ABOVE_UPPER";
  114. deviation = dev;
  115. }
  116. }
  117. if (direction == null && lower != null && measured.Value < lower.Value - semantics.ToleranceAbs)
  118. {
  119. var dev = lower.Value - measured.Value;
  120. if (lower.Value == 0m || dev / Math.Abs(lower.Value) > semantics.ToleranceRatio)
  121. {
  122. direction = "BELOW_LOWER";
  123. deviation = dev;
  124. }
  125. }
  126. if (direction == null) continue;
  127. var relatedObjectCode = row.GetString(objectCodeColumn) ?? string.Empty;
  128. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  129. var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
  130. var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
  131. hits.Add(new S8RuleHit
  132. {
  133. SourceRuleId = rule.Id,
  134. SourceRuleCode = definition.RuleCode,
  135. SourceObjectType = sourceObjectType,
  136. SourceObjectId = sourceObjectId,
  137. RelatedObjectCode = relatedObjectCode,
  138. ExceptionTypeCode = exceptionTypeCode,
  139. SceneCode = definition.SceneCode,
  140. Severity = parameters.Severity,
  141. DedupKey = dedupKey,
  142. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
  143. DetectedAt = detectedAt,
  144. Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
  145. OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId,
  146. ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId
  147. });
  148. }
  149. return hits;
  150. }
  151. /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
  152. internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  153. $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  154. private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
  155. {
  156. if (direction == "ABOVE_UPPER")
  157. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
  158. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
  159. }
  160. private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
  161. decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
  162. {
  163. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  164. foreach (var kv in row.Values)
  165. payload[kv.Key] = kv.Value;
  166. payload["measured_value"] = measured;
  167. payload["lower_bound"] = lower;
  168. payload["upper_bound"] = upper;
  169. payload["deviation"] = deviation;
  170. payload["direction"] = direction;
  171. payload["__ruleType"] = RuleTypeCode;
  172. payload["__sourceObjectType"] = sourceObjectType;
  173. payload["__sourceObjectId"] = sourceObjectId;
  174. payload["__exceptionTypeCode"] = exceptionTypeCode;
  175. return JsonSerializer.Serialize(payload);
  176. }
  177. }