using System.Text.Json; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules; /// /// OUT_OF_RANGE 类规则 evaluator("测量值越界")。 /// 三种最小模式:固定上限 / 固定下限 / 行内上下限。 /// /// S8-RULE-GOVERNANCE-BATCH1:上下限、容差、测量列、异常类型、去重身份 /// 改由 供给,不再从 params_json 解析 /// (G01 的 single_threshold 兼容协议随 params 解析一并退役 —— 它服务的是"用户在页面里 /// 拼阈值"这个已经取消的能力)。当前仓内**没有任何 OUT_OF_RANGE 规则定义**, /// 因此本 evaluator 实际不会被执行,这正是期望行为。 /// 判定(任一命中即 HIT): /// measured > upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| > toleranceRatio → ABOVE_UPPER /// measured < lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| > toleranceRatio → BELOW_LOWER /// upperBound / lowerBound 为 0 时 ratio 检查跳过(避免除零)。 /// 不做严重度阶梯、不做 SLA 升级、不做事件触发。 /// /// S8-DATASET-PROVIDER-FOUNDATION-1:取数改由 承担, /// 判定算法与迁移前逐行一致。 /// public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient { public const string RuleTypeCode = "OUT_OF_RANGE"; public string RuleType => RuleTypeCode; private readonly S8MonitoringDataGateway _dataGateway; private readonly IS8RuleCatalog _ruleCatalog; private readonly ILogger _logger; public S8OutOfRangeRuleEvaluator( S8MonitoringDataGateway dataGateway, IS8RuleCatalog ruleCatalog, ILogger logger) { _dataGateway = dataGateway; _ruleCatalog = ruleCatalog; _logger = logger; } public async Task> EvaluateAsync( long tenantId, long factoryId, AdoS8WatchRule rule, CancellationToken cancellationToken = default) { // S8-RULE-GOVERNANCE-BATCH1:没有代码定义的规则一律不执行(详见 S8TimeoutRuleEvaluator 同处注释)。 var effective = ResolveEffective(rule); // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 maxRows(env 优先,回退代码默认)。 var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger); var data = await _dataGateway.LoadAsync( tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken); return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now); } /// 解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。 private S8EffectiveRule ResolveEffective(AdoS8WatchRule rule) { var definition = _ruleCatalog.TryGet(rule.RuleCode) ?? throw new S8RuleEvaluatorException( S8RuleCatalog.ReasonNotFound, $"规则 {rule.RuleCode} 在当前版本中没有代码定义,不予执行"); if (!string.Equals(definition.RuleType, RuleTypeCode, StringComparison.Ordinal)) throw new S8RuleEvaluatorException( "rule_type_mismatch", $"规则 {rule.RuleCode} 的代码定义类型为 {definition.RuleType},不能由 OUT_OF_RANGE evaluator 执行"); if (definition.OutOfRange == null) throw new S8RuleEvaluatorException( "rule_semantics_missing", $"规则 {rule.RuleCode} 的代码定义缺少 OUT_OF_RANGE 判定语义"); return S8EffectiveRule.Resolve(rule, definition); } /// /// OUT_OF_RANGE 判定核心:只消费 canonical 行,不接触 SQL / DataTable / 数据源。 /// 判定算法与迁移前逐行一致;internal 暴露供契约测试直接驱动。 /// internal static List EvaluateRows( S8MonitoringRowSet rowSet, S8EffectiveRule effective, long tenantId, long factoryId, DateTime detectedAt) { var definition = effective.Definition; var semantics = definition.OutOfRange!; var parameters = effective.Parameters; var rule = effective.Row; var hits = new List(); var exceptionTypeCode = definition.ExceptionTypeCode; var sourceObjectType = definition.SourceObjectType; var measuredValueColumn = semantics.MeasuredValueColumn; var objectCodeColumn = semantics.RelatedObjectCodeColumn; var objectIdColumn = semantics.SourceObjectIdColumn; foreach (var row in rowSet.Rows) { var measured = row.GetDecimal(measuredValueColumn); if (measured == null) continue; // 行内上下限优先;缺失时回退到固定上下限。 decimal? lower = !string.IsNullOrWhiteSpace(semantics.LowerBoundColumn) ? row.GetDecimal(semantics.LowerBoundColumn!) ?? semantics.LowerBound : semantics.LowerBound; decimal? upper = !string.IsNullOrWhiteSpace(semantics.UpperBoundColumn) ? row.GetDecimal(semantics.UpperBoundColumn!) ?? semantics.UpperBound : semantics.UpperBound; if (lower == null && upper == null) continue; // 无界不命中 string? direction = null; decimal deviation = 0m; if (upper != null && measured.Value > upper.Value + semantics.ToleranceAbs) { var dev = measured.Value - upper.Value; if (upper.Value == 0m || dev / Math.Abs(upper.Value) > semantics.ToleranceRatio) { direction = "ABOVE_UPPER"; deviation = dev; } } if (direction == null && lower != null && measured.Value < lower.Value - semantics.ToleranceAbs) { var dev = lower.Value - measured.Value; if (lower.Value == 0m || dev / Math.Abs(lower.Value) > semantics.ToleranceRatio) { direction = "BELOW_LOWER"; deviation = dev; } } if (direction == null) continue; var relatedObjectCode = row.GetString(objectCodeColumn) ?? string.Empty; if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue; var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode; var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId); hits.Add(new S8RuleHit { SourceRuleId = rule.Id, SourceRuleCode = definition.RuleCode, SourceObjectType = sourceObjectType, SourceObjectId = sourceObjectId, RelatedObjectCode = relatedObjectCode, ExceptionTypeCode = exceptionTypeCode, SceneCode = definition.SceneCode, Severity = parameters.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), OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId) ?? parameters.DefaultOccurrenceDeptId, ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId) ?? parameters.DefaultResponsibleDeptId }); } return hits; } /// 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(S8MonitoringRow row, string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, decimal deviation, string direction, string exceptionTypeCode) { var payload = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var kv in row.Values) payload[kv.Key] = kv.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); } }