S8OutOfRangeRuleEvaluator.cs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. using System.Data;
  2. using System.Globalization;
  3. using System.Text.Json;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using SqlSugar;
  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. /// </summary>
  16. public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
  17. {
  18. public const string RuleTypeCode = "OUT_OF_RANGE";
  19. public string RuleType => RuleTypeCode;
  20. private const string SqlDataSourceType = "SQL";
  21. private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
  22. private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
  23. public S8OutOfRangeRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
  24. {
  25. _dataSourceRep = dataSourceRep;
  26. }
  27. public async Task<List<S8RuleHit>> EvaluateAsync(
  28. long tenantId,
  29. long factoryId,
  30. AdoS8WatchRule rule,
  31. IReadOnlyList<AdoS8AlertRule> alertRules,
  32. CancellationToken cancellationToken = default)
  33. {
  34. var hits = new List<S8RuleHit>();
  35. if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
  36. return hits;
  37. S8OutOfRangeParams parameters;
  38. try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
  39. catch { return hits; }
  40. if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
  41. return hits;
  42. var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
  43. ? DefaultExceptionTypeCode
  44. : parameters.ExceptionTypeCode!;
  45. var dataSource = await _dataSourceRep.AsQueryable()
  46. .Where(x => x.Id == rule.DataSourceId
  47. && x.TenantId == tenantId
  48. && x.FactoryId == factoryId
  49. && x.Enabled)
  50. .FirstAsync();
  51. if (dataSource == null
  52. || string.IsNullOrWhiteSpace(dataSource.Endpoint)
  53. || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
  54. return hits;
  55. DataTable table;
  56. try
  57. {
  58. using var db = CreateSqlScope(dataSource.Endpoint!);
  59. table = await db.Ado.GetDataTableAsync(rule.Expression!);
  60. }
  61. catch
  62. {
  63. return hits;
  64. }
  65. var detectedAt = DateTime.Now;
  66. var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
  67. ? rule.WatchObjectType
  68. : rule.SourceObjectType!;
  69. foreach (DataRow row in table.Rows)
  70. {
  71. var measured = ReadDecimal(row, parameters.MeasuredValueField!);
  72. if (measured == null) continue;
  73. // 行内上下限优先;缺失时回退到固定上下限。
  74. decimal? lower = !string.IsNullOrWhiteSpace(parameters.LowerBoundField)
  75. ? ReadDecimal(row, parameters.LowerBoundField!) ?? parameters.LowerBound
  76. : parameters.LowerBound;
  77. decimal? upper = !string.IsNullOrWhiteSpace(parameters.UpperBoundField)
  78. ? ReadDecimal(row, parameters.UpperBoundField!) ?? parameters.UpperBound
  79. : parameters.UpperBound;
  80. if (lower == null && upper == null) continue; // 无界不命中
  81. string? direction = null;
  82. decimal deviation = 0m;
  83. if (upper != null && measured.Value > upper.Value + parameters.ToleranceAbs)
  84. {
  85. var dev = measured.Value - upper.Value;
  86. if (upper.Value == 0m || dev / Math.Abs(upper.Value) > parameters.ToleranceRatio)
  87. {
  88. direction = "ABOVE_UPPER";
  89. deviation = dev;
  90. }
  91. }
  92. if (direction == null && lower != null && measured.Value < lower.Value - parameters.ToleranceAbs)
  93. {
  94. var dev = lower.Value - measured.Value;
  95. if (lower.Value == 0m || dev / Math.Abs(lower.Value) > parameters.ToleranceRatio)
  96. {
  97. direction = "BELOW_LOWER";
  98. deviation = dev;
  99. }
  100. }
  101. if (direction == null) continue;
  102. var objectCodeField = string.IsNullOrWhiteSpace(parameters.ObjectCodeField)
  103. ? "related_object_code"
  104. : parameters.ObjectCodeField!;
  105. var relatedObjectCode = ReadString(row, objectCodeField) ?? string.Empty;
  106. if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
  107. var sourceObjectId = string.IsNullOrWhiteSpace(parameters.ObjectIdField)
  108. ? relatedObjectCode
  109. : ReadString(row, parameters.ObjectIdField!) ?? relatedObjectCode;
  110. var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
  111. hits.Add(new S8RuleHit
  112. {
  113. SourceRuleId = rule.Id,
  114. SourceRuleCode = rule.RuleCode,
  115. SourceObjectType = sourceObjectType,
  116. SourceObjectId = sourceObjectId,
  117. RelatedObjectCode = relatedObjectCode,
  118. ExceptionTypeCode = exceptionTypeCode,
  119. SceneCode = rule.SceneCode,
  120. Severity = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity,
  121. DedupKey = dedupKey,
  122. SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
  123. DetectedAt = detectedAt,
  124. Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
  125. DataSourceId = dataSource.Id,
  126. OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"),
  127. ResponsibleDeptId = ReadLong(row, "responsible_dept_id")
  128. });
  129. }
  130. return hits;
  131. }
  132. private SqlSugarScope CreateSqlScope(string connectionString)
  133. {
  134. var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
  135. return new SqlSugarScope(new ConnectionConfig
  136. {
  137. ConfigId = $"s8-oor-eval-{Guid.NewGuid():N}",
  138. DbType = dbType,
  139. ConnectionString = connectionString,
  140. InitKeyType = InitKeyType.Attribute,
  141. IsAutoCloseConnection = true
  142. });
  143. }
  144. /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
  145. internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
  146. $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
  147. private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
  148. {
  149. if (direction == "ABOVE_UPPER")
  150. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
  151. return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
  152. }
  153. private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId,
  154. decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
  155. {
  156. var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  157. foreach (DataColumn column in row.Table.Columns)
  158. {
  159. var value = row[column];
  160. payload[column.ColumnName] = value == DBNull.Value ? null : value;
  161. }
  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. private static string? ReadString(DataRow row, string columnName)
  174. {
  175. if (string.IsNullOrWhiteSpace(columnName)) return null;
  176. if (!row.Table.Columns.Contains(columnName)) return null;
  177. var v = row[columnName];
  178. return v == DBNull.Value ? null : Convert.ToString(v)?.Trim();
  179. }
  180. private static decimal? ReadDecimal(DataRow row, string columnName)
  181. {
  182. if (string.IsNullOrWhiteSpace(columnName)) return null;
  183. if (!row.Table.Columns.Contains(columnName)) return null;
  184. var v = row[columnName];
  185. if (v == DBNull.Value) return null;
  186. return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var r) ? r : null;
  187. }
  188. private static long? ReadLong(DataRow row, string columnName)
  189. {
  190. if (!row.Table.Columns.Contains(columnName)) return null;
  191. var v = row[columnName];
  192. if (v == DBNull.Value) return null;
  193. return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
  194. }
  195. }