S8OutOfRangeRuleEvaluator.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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. AdoS8WatchRule rule,
  45. CancellationToken cancellationToken = default)
  46. {
  47. // S8-RULE-GOVERNANCE-BATCH1:没有代码定义的规则一律不执行(详见 S8TimeoutRuleEvaluator 同处注释)。
  48. var effective = ResolveEffective(rule);
  49. // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。
  50. var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
  51. var data = await _dataGateway.LoadAsync(
  52. tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
  53. return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
  54. }
  55. /// <summary>解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。</summary>
  56. private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule)
  57. {
  58. var definition = _ruleCatalog.TryGet(rule.RuleCode)
  59. ?? throw new S8RuleEvaluatorException(
  60. S8RuleCatalog.ReasonNotFound,
  61. $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行");
  62. if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal))
  63. throw new S8RuleEvaluatorException(
  64. "rule_type_mismatch",
  65. $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 OUT_OF_RANGE evaluator 执行");
  66. if (definition.OutOfRange == null)
  67. throw new S8RuleEvaluatorException(
  68. "rule_semantics_missing",
  69. $"规则 {rule.RuleCode} 的代码定义缺少 OUT_OF_RANGE 判定语义");
  70. return S8EffectiveRule.Resolve(rule, definition);
  71. }
  72. /// <summary>
  73. /// OUT_OF_RANGE 判定核心:只消费 canonical 行,不接触 SQL / DataTable / 数据源。
  74. /// 判定算法与迁移前逐行一致;internal 暴露供契约测试直接驱动。
  75. /// </summary>
  76. internal static List<S8RuleHit> EvaluateRows(
  77. S8MonitoringRowSet rowSet,
  78. S8EffectiveRule effective,
  79. long tenantId,
  80. DateTime detectedAt)
  81. {
  82. var definition = effective.Definition;
  83. var semantics = definition.OutOfRange!;
  84. var parameters = effective.Parameters;
  85. var rule = effective.Row;
  86. var hits = new List<S8RuleHit>();
  87. var exceptionTypeCode = definition.ExceptionTypeCode;
  88. var sourceObjectType = definition.SourceObjectType;
  89. var measuredValueColumn = semantics.MeasuredValueColumn;
  90. var objectCodeColumn = semantics.RelatedObjectCodeColumn;
  91. var objectIdColumn = semantics.SourceObjectIdColumn;
  92. foreach (var row in rowSet.Rows)
  93. {
  94. var measured = row.GetDecimal(measuredValueColumn);
  95. if (measured == null) continue;
  96. // 行内上下限优先;缺失时回退到固定上下限。
  97. decimal? lower = !string.IsNullOrWhiteSpace(semantics.LowerBoundColumn)
  98. ? row.GetDecimal(semantics.LowerBoundColumn!) ?? semantics.LowerBound
  99. : semantics.LowerBound;
  100. decimal? upper = !string.IsNullOrWhiteSpace(semantics.UpperBoundColumn)
  101. ? row.GetDecimal(semantics.UpperBoundColumn!) ?? semantics.UpperBound
  102. : semantics.UpperBound;
  103. if (lower == null && upper == null) continue; // 无界不命中
  104. string? direction = null;
  105. decimal deviation = 0m;
  106. if (upper != null && measured.Value > upper.Value + semantics.ToleranceAbs)
  107. {
  108. var dev = measured.Value - upper.Value;
  109. if (upper.Value == 0m || dev / Math.Abs(upper.Value) > semantics.ToleranceRatio)
  110. {
  111. direction = "ABOVE_UPPER";
  112. deviation = dev;
  113. }
  114. }
  115. if (direction == null && lower != null && measured.Value < lower.Value - semantics.ToleranceAbs)
  116. {
  117. var dev = lower.Value - measured.Value;
  118. if (lower.Value == 0m || dev / Math.Abs(lower.Value) > semantics.ToleranceRatio)
  119. {
  120. direction = "BELOW_LOWER";
  121. deviation = dev;
  122. }
  123. }
  124. if (direction == null) continue;
  125. var relatedObjectCode = row.GetString(objectCodeColumn) ?? string.Empty;
  126. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  127. var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
  128. var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
  129. hits.Add(new S8RuleHit
  130. {
  131. SourceRuleId = rule.Id,
  132. SourceRuleCode = definition.RuleCode,
  133. SourceObjectType = sourceObjectType,
  134. SourceObjectId = sourceObjectId,
  135. RelatedObjectCode = relatedObjectCode,
  136. ExceptionTypeCode = exceptionTypeCode,
  137. SceneCode = definition.SceneCode,
  138. Severity = parameters.Severity,
  139. DedupKey = dedupKey,
  140. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
  141. DetectedAt = detectedAt,
  142. Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
  143. OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId,
  144. ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId
  145. });
  146. }
  147. return hits;
  148. }
  149. /// <summary>
  150. /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
  151. ///
  152. /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
  153. /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
  154. /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
  155. ///
  156. /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
  157. /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
  158. /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
  159. /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
  160. /// 而不是把工厂重新变回隔离维度。</para>
  161. ///
  162. /// internal 暴露供测试。
  163. /// </summary>
  164. internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  165. $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  166. private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
  167. {
  168. if (direction == "ABOVE_UPPER")
  169. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
  170. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
  171. }
  172. private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
  173. decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
  174. {
  175. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  176. foreach (var kv in row.Values)
  177. payload[kv.Key] = kv.Value;
  178. payload["measured_value"] = measured;
  179. payload["lower_bound"] = lower;
  180. payload["upper_bound"] = upper;
  181. payload["deviation"] = deviation;
  182. payload["direction"] = direction;
  183. payload["__ruleType"] = RuleTypeCode;
  184. payload["__sourceObjectType"] = sourceObjectType;
  185. payload["__sourceObjectId"] = sourceObjectId;
  186. payload["__exceptionTypeCode"] = exceptionTypeCode;
  187. return JsonSerializer.Serialize(payload);
  188. }
  189. }