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; /// /// 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 升级、不做事件触发。 /// 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 _dataSourceRep; public S8OutOfRangeRuleEvaluator(SqlSugarRepository dataSourceRep) { _dataSourceRep = dataSourceRep; } public async Task> EvaluateAsync( long tenantId, long factoryId, AdoS8WatchRule rule, IReadOnlyList alertRules, CancellationToken cancellationToken = default) { var hits = new List(); // 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 }); } /// R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。 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(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; } }