| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- using System.Data;
- using System.Globalization;
- using System.Text.Json;
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure.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>();
- // R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
- if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
- throw new S8RuleEvaluatorException("rule_not_configured", $"OUT_OF_RANGE 规则 {rule.RuleCode} 缺少 expression 或 params_json");
- S8OutOfRangeParams parameters;
- try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
- catch (Exception ex) { throw new S8RuleEvaluatorException("params_parse_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} params_json 解析失败:{ex.Message}", ex); }
- if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
- throw new S8RuleEvaluatorException("params_schema_invalid", $"OUT_OF_RANGE 规则 {rule.RuleCode} params 缺少必填字段 measuredValueField");
- 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))
- throw new S8RuleEvaluatorException("data_source_unavailable", $"OUT_OF_RANGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
- DataTable table;
- try
- {
- using var db = CreateSqlScope(dataSource.Endpoint!);
- table = await db.Ado.GetDataTableAsync(rule.Expression!);
- }
- catch (Exception ex)
- {
- throw new S8RuleEvaluatorException("query_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
- }
- 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 = S8SeverityCode.Normalize(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;
- }
- }
|