S8OutOfRangeRuleEvaluator.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  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. // S8-RULE-LIFECYCLE-CREATE-GATE-1:预警型规则的首次立案闸门。
  130. //
  131. // 只对显式声明 CreateOnlyBeforeDueAt 的规则生效;其余规则恒 true,行为逐字不变。
  132. //
  133. // 到期日缺失时判 true 而不是 false:闸门的职责是"到期后别开新案子",
  134. // 不是"没有到期日就别报警"。缺列时静默拒绝建单会让一条规则表现为
  135. // 「每轮都命中、一条异常也建不出来」——正是本仓反复在防的那类静默失败。
  136. var createEligible = true;
  137. if (definition.CreateOnlyBeforeDueAt)
  138. {
  139. var dueAt = row.GetDateTime(S8CanonicalColumns.DueAt);
  140. createEligible = dueAt == null || dueAt.Value >= detectedAt;
  141. }
  142. hits.Add(new S8RuleHit
  143. {
  144. CreateEligible = createEligible,
  145. SourceRuleId = rule.Id,
  146. SourceRuleCode = definition.RuleCode,
  147. SourceObjectType = sourceObjectType,
  148. SourceObjectId = sourceObjectId,
  149. RelatedObjectCode = relatedObjectCode,
  150. ExceptionTypeCode = exceptionTypeCode,
  151. SceneCode = definition.SceneCode,
  152. Severity = parameters.Severity,
  153. DedupKey = dedupKey,
  154. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
  155. DetectedAt = detectedAt,
  156. Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
  157. OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId,
  158. ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId
  159. });
  160. }
  161. return hits;
  162. }
  163. /// <summary>
  164. /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
  165. ///
  166. /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
  167. /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
  168. /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
  169. ///
  170. /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
  171. /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
  172. /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
  173. /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
  174. /// 而不是把工厂重新变回隔离维度。</para>
  175. ///
  176. /// internal 暴露供测试。
  177. /// </summary>
  178. internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  179. $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  180. private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
  181. {
  182. if (direction == "ABOVE_UPPER")
  183. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
  184. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
  185. }
  186. private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
  187. decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
  188. {
  189. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  190. foreach (var kv in row.Values)
  191. payload[kv.Key] = kv.Value;
  192. payload["measured_value"] = measured;
  193. payload["lower_bound"] = lower;
  194. payload["upper_bound"] = upper;
  195. payload["deviation"] = deviation;
  196. payload["direction"] = direction;
  197. payload["__ruleType"] = RuleTypeCode;
  198. payload["__sourceObjectType"] = sourceObjectType;
  199. payload["__sourceObjectId"] = sourceObjectId;
  200. payload["__exceptionTypeCode"] = exceptionTypeCode;
  201. return JsonSerializer.Serialize(payload);
  202. }
  203. }