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,
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, rule, RuleTypeCode, maxRows, cancellationToken);
return EvaluateRows(data.RowSet, effective, tenantId, 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,
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, 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;
}
///
/// 构造 dedup_key:T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。
///
/// S8-TENANT-ONLY-BATCH5:去掉了 :F{factory} 段。
/// 租户隔离由首段 T{tenant} 与各查询的 tenant_id 谓词双重保证;
/// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。
///
/// 真库证据(迁移前取证):dwd_supplier_delivery 全表 39854 行 / 4 租户 / 97 个快照,
/// (tenant_id, stat_date, po_no, po_line) 重复组为 0,跨工厂碰撞组为 0。
/// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
/// SourceObjectId contract 负责(例如把工厂编号并进对象标识),
/// 而不是把工厂重新变回隔离维度。
///
/// internal 暴露供测试。
///
internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
$"T{tenantId}: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);
}
}