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;
}
}