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 Microsoft.Extensions.Logging;
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 升级、不做事件触发。
///
/// S8-DATASET-PROVIDER-FOUNDATION-1:取数改由 承担,
/// 判定算法与迁移前逐行一致。
///
public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
{
public const string RuleTypeCode = "OUT_OF_RANGE";
public string RuleType => RuleTypeCode;
private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
// S8-WATCH-EXPRESSION-COLUMN-CONTRACT-SHORTAGE-OUTOFRANGE-FIX-1:S8ConfigDraftService.BuildExpression
// 把 VALUE_RANGE 结果列统一别名为以下 canonical 名(无论源表真实列名为何)。evaluator 优先按 canonical
// 读取,仅当结果集不含 canonical 列时才回退到 params_json 指定的真实列名(兼容历史/手工规则)。
// 上下限为行内可选字段、无 canonical 契约,沿用 params 既有逻辑不变。
private const string CanonicalMeasuredValueColumn = S8CanonicalColumns.MeasuredValue;
private const string CanonicalSourceObjectIdColumn = S8CanonicalColumns.SourceObjectId;
private const string CanonicalRelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode;
private readonly S8MonitoringDataGateway _dataGateway;
private readonly ILogger _logger;
public S8OutOfRangeRuleEvaluator(
S8MonitoringDataGateway dataGateway,
ILogger logger)
{
_dataGateway = dataGateway;
_logger = logger;
}
public async Task> EvaluateAsync(
long tenantId,
long factoryId,
AdoS8WatchRule rule,
IReadOnlyList alertRules,
CancellationToken cancellationToken = default)
{
// R5 evaluator 失败语义保护:与 TIMEOUT/SHORTAGE 同形,所有"非命中判定"路径改抛 S8RuleEvaluatorException。
var accessMode = S8DataAccessMode.Resolve(rule.DataAccessMode);
var expressionRequired = accessMode == S8DataAccessMode.LegacySql;
if ((expressionRequired && 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");
// S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
var data = await _dataGateway.LoadAsync(
tenantId, factoryId, rule, RuleTypeCode, timeoutSeconds, maxRows, cancellationToken);
return EvaluateRows(data.RowSet, parameters, rule, tenantId, factoryId, data.DataSourceId, DateTime.Now);
}
///
/// OUT_OF_RANGE 判定核心:只消费 canonical 行,不接触 SQL / DataTable / 数据源。
/// 判定算法与迁移前逐行一致;internal 暴露供契约测试直接驱动。
///
internal static List EvaluateRows(
S8MonitoringRowSet rowSet,
S8OutOfRangeParams parameters,
AdoS8WatchRule rule,
long tenantId,
long factoryId,
long dataSourceId,
DateTime detectedAt)
{
var hits = new List();
var exceptionTypeCode = string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode)
? DefaultExceptionTypeCode
: parameters.ExceptionTypeCode!;
var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
? rule.WatchObjectType
: rule.SourceObjectType!;
// 结果列名一次性解析(结果集列在整个结果集内稳定):canonical 优先,缺失回退 params 真实列名。
var measuredValueColumn = ResolveResultColumn(rowSet, CanonicalMeasuredValueColumn, parameters.MeasuredValueField);
var objectCodeColumn = ResolveResultColumn(rowSet, CanonicalRelatedObjectCodeColumn, parameters.ObjectCodeField);
var objectIdColumn = ResolveResultColumn(rowSet, CanonicalSourceObjectIdColumn, parameters.ObjectIdField);
foreach (var row in rowSet.Rows)
{
var measured = row.GetDecimal(measuredValueColumn);
if (measured == null) continue;
// 行内上下限优先;缺失时回退到固定上下限。
decimal? lower = !string.IsNullOrWhiteSpace(parameters.LowerBoundField)
? row.GetDecimal(parameters.LowerBoundField!) ?? parameters.LowerBound
: parameters.LowerBound;
decimal? upper = !string.IsNullOrWhiteSpace(parameters.UpperBoundField)
? row.GetDecimal(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 relatedObjectCode = row.GetString(objectCodeColumn) ?? string.Empty;
if (string.IsNullOrWhiteSpace(relatedObjectCode)) continue;
var sourceObjectId = row.GetString(objectIdColumn) ?? 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 = dataSourceId,
OccurrenceDeptId = row.GetLong(S8CanonicalColumns.OccurrenceDeptId),
ResponsibleDeptId = row.GetLong(S8CanonicalColumns.ResponsibleDeptId)
});
}
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);
}
///
/// 结果列名解析:BuildExpression 已把 VALUE_RANGE 结果列统一别名为 canonical(measured_value/
/// source_object_id/related_object_code)。优先返回 canonical 列名;仅当结果集不含 canonical 列时,
/// 回退到 params_json 指定的真实列名(兼容历史/手工规则)。仅在 canonical 与 params 字段之间二选一,
/// 不新增无依据兜底字段。
///
private static string ResolveResultColumn(S8MonitoringRowSet rowSet, string canonicalColumn, string? paramsColumn)
{
if (rowSet.HasColumn(canonicalColumn)) return canonicalColumn;
return string.IsNullOrWhiteSpace(paramsColumn) ? canonicalColumn : paramsColumn!;
}
}