|
|
@@ -0,0 +1,224 @@
|
|
|
+using System.Data;
|
|
|
+using System.Globalization;
|
|
|
+using System.Text.Json;
|
|
|
+using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
+using SqlSugar;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// R3 OUT_OF_RANGE 类规则 evaluator MVP("测量值越界")。
|
|
|
+/// 三种最小模式:固定上限 / 固定下限 / 行内上下限;兼容 G01 single_threshold 协议。
|
|
|
+/// 判定(任一命中即 HIT):
|
|
|
+/// measured > upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| > toleranceRatio → ABOVE_UPPER
|
|
|
+/// measured < lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| > toleranceRatio → BELOW_LOWER
|
|
|
+/// upperBound / lowerBound 为 0 时 ratio 检查跳过(避免除零)。
|
|
|
+/// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
|
|
|
+/// </summary>
|
|
|
+public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
|
|
|
+{
|
|
|
+ public const string RuleTypeCode = "OUT_OF_RANGE";
|
|
|
+ public string RuleType => RuleTypeCode;
|
|
|
+
|
|
|
+ private const string SqlDataSourceType = "SQL";
|
|
|
+ private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
|
|
|
+
|
|
|
+ private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
|
|
|
+
|
|
|
+ public S8OutOfRangeRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
|
|
|
+ {
|
|
|
+ _dataSourceRep = dataSourceRep;
|
|
|
+ }
|
|
|
+
|
|
|
+ public async Task<List<S8RuleHit>> EvaluateAsync(
|
|
|
+ long tenantId,
|
|
|
+ long factoryId,
|
|
|
+ AdoS8WatchRule rule,
|
|
|
+ IReadOnlyList<AdoS8AlertRule> alertRules,
|
|
|
+ CancellationToken cancellationToken = default)
|
|
|
+ {
|
|
|
+ var hits = new List<S8RuleHit>();
|
|
|
+
|
|
|
+ if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
|
|
|
+ return hits;
|
|
|
+
|
|
|
+ S8OutOfRangeParams parameters;
|
|
|
+ try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
|
|
|
+ catch { return hits; }
|
|
|
+
|
|
|
+ if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
|
|
|
+ return hits;
|
|
|
+
|
|
|
+ var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
|
|
|
+ ? DefaultExceptionTypeCode
|
|
|
+ : parameters.ExceptionTypeCode!;
|
|
|
+
|
|
|
+ var dataSource = await _dataSourceRep.AsQueryable()
|
|
|
+ .Where(x => x.Id == rule.DataSourceId
|
|
|
+ && x.TenantId == tenantId
|
|
|
+ && x.FactoryId == factoryId
|
|
|
+ && x.Enabled)
|
|
|
+ .FirstAsync();
|
|
|
+ if (dataSource == null
|
|
|
+ || string.IsNullOrWhiteSpace(dataSource.Endpoint)
|
|
|
+ || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
|
|
|
+ return hits;
|
|
|
+
|
|
|
+ DataTable table;
|
|
|
+ try
|
|
|
+ {
|
|
|
+ using var db = CreateSqlScope(dataSource.Endpoint!);
|
|
|
+ table = await db.Ado.GetDataTableAsync(rule.Expression!);
|
|
|
+ }
|
|
|
+ catch
|
|
|
+ {
|
|
|
+ return hits;
|
|
|
+ }
|
|
|
+
|
|
|
+ var detectedAt = DateTime.Now;
|
|
|
+ var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
|
|
|
+ ? rule.WatchObjectType
|
|
|
+ : rule.SourceObjectType!;
|
|
|
+
|
|
|
+ foreach (DataRow row in table.Rows)
|
|
|
+ {
|
|
|
+ var measured = ReadDecimal(row, parameters.MeasuredValueField!);
|
|
|
+ if (measured == null) continue;
|
|
|
+
|
|
|
+ // 行内上下限优先;缺失时回退到固定上下限。
|
|
|
+ decimal? lower = !string.IsNullOrWhiteSpace(parameters.LowerBoundField)
|
|
|
+ ? ReadDecimal(row, parameters.LowerBoundField!) ?? parameters.LowerBound
|
|
|
+ : parameters.LowerBound;
|
|
|
+ decimal? upper = !string.IsNullOrWhiteSpace(parameters.UpperBoundField)
|
|
|
+ ? ReadDecimal(row, parameters.UpperBoundField!) ?? parameters.UpperBound
|
|
|
+ : parameters.UpperBound;
|
|
|
+
|
|
|
+ if (lower == null && upper == null) continue; // 无界不命中
|
|
|
+
|
|
|
+ string? direction = null;
|
|
|
+ decimal deviation = 0m;
|
|
|
+ if (upper != null && measured.Value > upper.Value + parameters.ToleranceAbs)
|
|
|
+ {
|
|
|
+ var dev = measured.Value - upper.Value;
|
|
|
+ if (upper.Value == 0m || dev / Math.Abs(upper.Value) > parameters.ToleranceRatio)
|
|
|
+ {
|
|
|
+ direction = "ABOVE_UPPER";
|
|
|
+ deviation = dev;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (direction == null && lower != null && measured.Value < lower.Value - parameters.ToleranceAbs)
|
|
|
+ {
|
|
|
+ var dev = lower.Value - measured.Value;
|
|
|
+ if (lower.Value == 0m || dev / Math.Abs(lower.Value) > parameters.ToleranceRatio)
|
|
|
+ {
|
|
|
+ direction = "BELOW_LOWER";
|
|
|
+ deviation = dev;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (direction == null) continue;
|
|
|
+
|
|
|
+ var objectCodeField = string.IsNullOrWhiteSpace(parameters.ObjectCodeField)
|
|
|
+ ? "related_object_code"
|
|
|
+ : parameters.ObjectCodeField!;
|
|
|
+ var relatedObjectCode = ReadString(row, objectCodeField) ?? string.Empty;
|
|
|
+ if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
|
|
|
+
|
|
|
+ var sourceObjectId = string.IsNullOrWhiteSpace(parameters.ObjectIdField)
|
|
|
+ ? relatedObjectCode
|
|
|
+ : ReadString(row, parameters.ObjectIdField!) ?? relatedObjectCode;
|
|
|
+
|
|
|
+ var dedupKey = BuildDedupKey(tenantId, factoryId, rule.RuleCode, sourceObjectType, sourceObjectId);
|
|
|
+
|
|
|
+ hits.Add(new S8RuleHit
|
|
|
+ {
|
|
|
+ SourceRuleId = rule.Id,
|
|
|
+ SourceRuleCode = rule.RuleCode,
|
|
|
+ SourceObjectType = sourceObjectType,
|
|
|
+ SourceObjectId = sourceObjectId,
|
|
|
+ RelatedObjectCode = relatedObjectCode,
|
|
|
+ ExceptionTypeCode = exceptionTypeCode,
|
|
|
+ SceneCode = rule.SceneCode,
|
|
|
+ Severity = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity,
|
|
|
+ DedupKey = dedupKey,
|
|
|
+ SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, measured.Value, lower, upper, deviation, direction, exceptionTypeCode),
|
|
|
+ DetectedAt = detectedAt,
|
|
|
+ Title = BuildTitle(sourceObjectType, sourceObjectId, measured.Value, lower, upper, direction),
|
|
|
+ DataSourceId = dataSource.Id,
|
|
|
+ OccurrenceDeptId = ReadLong(row, "occurrence_dept_id"),
|
|
|
+ ResponsibleDeptId = ReadLong(row, "responsible_dept_id")
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return hits;
|
|
|
+ }
|
|
|
+
|
|
|
+ private SqlSugarScope CreateSqlScope(string connectionString)
|
|
|
+ {
|
|
|
+ var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
|
|
|
+ return new SqlSugarScope(new ConnectionConfig
|
|
|
+ {
|
|
|
+ ConfigId = $"s8-oor-eval-{Guid.NewGuid():N}",
|
|
|
+ DbType = dbType,
|
|
|
+ ConnectionString = connectionString,
|
|
|
+ InitKeyType = InitKeyType.Attribute,
|
|
|
+ IsAutoCloseConnection = true
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
|
|
|
+ internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
|
|
|
+ $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
|
|
|
+
|
|
|
+ private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
|
|
|
+ {
|
|
|
+ if (direction == "ABOVE_UPPER")
|
|
|
+ return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 超过上限 {upper:0.##}";
|
|
|
+ return $"[超差] {sourceObjectType} {sourceObjectId} 测量值 {measured:0.##} 低于下限 {lower:0.##}";
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId,
|
|
|
+ decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode)
|
|
|
+ {
|
|
|
+ var payload = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
|
|
+ foreach (DataColumn column in row.Table.Columns)
|
|
|
+ {
|
|
|
+ var value = row[column];
|
|
|
+ payload[column.ColumnName] = value == DBNull.Value ? null : value;
|
|
|
+ }
|
|
|
+ payload["measured_value"] = measured;
|
|
|
+ payload["lower_bound"] = lower;
|
|
|
+ payload["upper_bound"] = upper;
|
|
|
+ payload["deviation"] = deviation;
|
|
|
+ payload["direction"] = direction;
|
|
|
+ payload["__ruleType"] = RuleTypeCode;
|
|
|
+ payload["__sourceObjectType"] = sourceObjectType;
|
|
|
+ payload["__sourceObjectId"] = sourceObjectId;
|
|
|
+ payload["__exceptionTypeCode"] = exceptionTypeCode;
|
|
|
+ return JsonSerializer.Serialize(payload);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string? ReadString(DataRow row, string columnName)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(columnName)) return null;
|
|
|
+ if (!row.Table.Columns.Contains(columnName)) return null;
|
|
|
+ var v = row[columnName];
|
|
|
+ return v == DBNull.Value ? null : Convert.ToString(v)?.Trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static decimal? ReadDecimal(DataRow row, string columnName)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(columnName)) return null;
|
|
|
+ if (!row.Table.Columns.Contains(columnName)) return null;
|
|
|
+ var v = row[columnName];
|
|
|
+ if (v == DBNull.Value) return null;
|
|
|
+ return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var r) ? r : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static long? ReadLong(DataRow row, string columnName)
|
|
|
+ {
|
|
|
+ if (!row.Table.Columns.Contains(columnName)) return null;
|
|
|
+ var v = row[columnName];
|
|
|
+ if (v == DBNull.Value) return null;
|
|
|
+ return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var r) ? r : null;
|
|
|
+ }
|
|
|
+}
|