Ver código fonte

feat(s8): add out-of-range rule evaluator MVP

YY968XX 2 meses atrás
pai
commit
e217b3a6cc

+ 171 - 0
scripts/s8/r3-out-of-range-regression.sh

@@ -0,0 +1,171 @@
+#!/usr/bin/env bash
+# R3-OUT_OF_RANGE-REWRITE-1 regression script (dev/test only, aidopdev).
+#
+# Drives /api/aidop/s8/watch-debug/run-once twice and verifies OUT_OF_RANGE
+# end-to-end after the rewrite while ensuring TIMEOUT (id=52) and SHORTAGE
+# (id=53) compat paths do not regress:
+#   1. baseline 13 unchanged;
+#   2. G01_TEST_WATCH enabled, rule_type=OUT_OF_RANGE;
+#   3. run-once #1 + #2 HTTP 200, count>=3, all skipped duplicate_pending,
+#      no created/failed entries;
+#   4. only ONE active (status != CLOSED) OUT_OF_RANGE exception
+#      under source_rule_id=1 (i.e. id=34 backfilled, no duplicate created);
+#   5. id=34 dedup_key/source_rule_code/source_object_type/source_object_id
+#      backfilled, source_payload contains measured_value/upper_bound/
+#      deviation/direction + 4 __ metadata fields;
+#   6. last_detected_at refreshed between run #1 and run #2 for all three;
+#   7. TIMEOUT id=52 and SHORTAGE id=53 still active and refreshed.
+
+set -euo pipefail
+
+PROJECT_DIR="${PROJECT_DIR:-/home/yy968/work/New9S/AiDOPWarehouse}"
+STORAGE_STATE="${PROJECT_DIR}/Web/tests/e2e/.auth/storage-state.json"
+BACKEND_BASE="${BACKEND_BASE:-http://localhost:5005}"
+TENANT_ID="${TENANT_ID:-1}"
+FACTORY_ID="${FACTORY_ID:-1}"
+DB_HOST="${DB_HOST:-123.60.180.165}"
+DB_PORT="${DB_PORT:-3306}"
+DB_NAME="${DB_NAME:-aidopdev}"
+DB_USER="${DB_USER:-aidopremote}"
+DB_PASS="${DB_PASS:-1234567890aiDOP#}"
+
+fail() { echo "FAIL: $*" >&2; exit 1; }
+ok()   { echo "OK: $*"; }
+
+[[ "${DB_NAME}" == "aidopdev" ]] || fail "DB_NAME must be aidopdev, got ${DB_NAME}"
+[[ -f "${STORAGE_STATE}" ]] || fail "storage state not found: ${STORAGE_STATE}"
+
+AT="$(python3 -c "
+import json,sys
+d=json.load(open(sys.argv[1]))
+for kv in d['origins'][0]['localStorage']:
+    if kv['name']=='admin.net:access-token':
+        print(kv['value'].strip('\"')); break
+" "${STORAGE_STATE}")"
+XAT="$(python3 -c "
+import json,sys
+d=json.load(open(sys.argv[1]))
+for kv in d['origins'][0]['localStorage']:
+    if kv['name']=='admin.net:x-access-token':
+        print(kv['value'].strip('\"')); break
+" "${STORAGE_STATE}")"
+[[ -n "${AT}" && "${AT}" != "null" ]] || fail "access-token missing in storage-state"
+[[ -n "${XAT}" && "${XAT}" != "null" ]] || fail "x-access-token missing in storage-state"
+
+mysql_query() {
+  MYSQL_PWD="${DB_PASS}" mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" "${DB_NAME}" \
+    --default-character-set=utf8mb4 --connect-timeout=8 -N -B -e "$1" 2>/dev/null
+}
+
+run_once() {
+  curl -fsS --max-time 30 -X POST \
+    -H "Authorization: Bearer ${AT}" -H "X-Access-Token: ${XAT}" \
+    "${BACKEND_BASE}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}"
+}
+
+baseline=$(mysql_query "SELECT COUNT(*) FROM ado_s8_exception_type WHERE tenant_id=0 AND factory_id=0;")
+[[ "${baseline}" == "13" ]] || fail "baseline regression: expected 13, got ${baseline}"
+ok "baseline = 13"
+
+precheck=$(mysql_query "SELECT COUNT(*) FROM ado_s8_watch_rule WHERE rule_code='G01_TEST_WATCH' AND rule_type='OUT_OF_RANGE' AND enabled=1;")
+[[ "${precheck}" == "1" ]] || fail "G01_TEST_WATCH not present, disabled, or not OUT_OF_RANGE (got '${precheck}')"
+ok "G01_TEST_WATCH enabled with rule_type=OUT_OF_RANGE"
+
+resp1="$(run_once)"
+count1=$(printf '%s' "${resp1}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('count',0))")
+[[ "${count1}" -ge 3 ]] || fail "first run-once expected at least 3 results (OOR + TIMEOUT + SHORTAGE), got ${count1}"
+ok "first run-once HTTP 200, count=${count1}"
+
+failed1=$(printf '%s' "${resp1}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+print(sum(1 for r in d.get('results',[]) if r.get('errorMessage') or r.get('reason')=='create_failed'))
+")
+[[ "${failed1}" == "0" ]] || fail "first run-once expected failed=0, got ${failed1}"
+ok "first run-once failed=0"
+
+oor_ok=$(printf '%s' "${resp1}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+hit=any(r for r in d.get('results',[]) if str(r.get('sourceRuleId'))=='1' and r.get('skipped') and str(r.get('matchedExceptionId'))=='34')
+print('YES' if hit else 'NO')
+")
+[[ "${oor_ok}" == "YES" ]] || fail "OUT_OF_RANGE compat path: expected skipped duplicate id=34"
+ok "OUT_OF_RANGE: skipped duplicate id=34"
+
+timeout_ok=$(printf '%s' "${resp1}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+hit=any(r for r in d.get('results',[]) if str(r.get('sourceRuleId'))=='5' and r.get('skipped') and str(r.get('matchedExceptionId'))=='52')
+print('YES' if hit else 'NO')
+")
+[[ "${timeout_ok}" == "YES" ]] || fail "TIMEOUT regression: expected skipped duplicate id=52"
+ok "TIMEOUT: skipped duplicate id=52"
+
+shortage_ok=$(printf '%s' "${resp1}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+hit=any(r for r in d.get('results',[]) if str(r.get('sourceRuleId'))=='6' and r.get('skipped') and str(r.get('matchedExceptionId'))=='53')
+print('YES' if hit else 'NO')
+")
+[[ "${shortage_ok}" == "YES" ]] || fail "SHORTAGE regression: expected skipped duplicate id=53"
+ok "SHORTAGE: skipped duplicate id=53"
+
+# id=34 backfill verification
+backfill=$(mysql_query "SELECT CONCAT_WS('|', dedup_key, source_rule_code, source_object_type, source_object_id) FROM ado_s8_exception WHERE id=34;")
+[[ "${backfill}" == "T1:F1:RG01_TEST_WATCH:DEVICE:EQ-01|G01_TEST_WATCH|DEVICE|EQ-01" ]] \
+  || fail "id=34 backfill mismatch, got '${backfill}'"
+ok "id=34 backfilled: dedup_key/source_rule_code/source_object_type/source_object_id"
+
+payload=$(mysql_query "SELECT source_payload FROM ado_s8_exception WHERE id=34;")
+for field in measured_value upper_bound deviation direction __ruleType __sourceObjectType __sourceObjectId __exceptionTypeCode; do
+  echo "${payload}" | grep -q "${field}" || fail "id=34 source_payload missing ${field}"
+done
+ok "id=34 source_payload contains measured/upper/deviation/direction + 4 metadata fields"
+
+before_oor=$(mysql_query "SELECT last_detected_at FROM ado_s8_exception WHERE id=34;")
+before_to=$(mysql_query "SELECT last_detected_at FROM ado_s8_exception WHERE id=52;")
+before_sh=$(mysql_query "SELECT last_detected_at FROM ado_s8_exception WHERE id=53;")
+
+sleep 1
+resp2="$(run_once)"
+count2=$(printf '%s' "${resp2}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('count',0))")
+[[ "${count2}" -ge 3 ]] || fail "second run-once expected at least 3 results, got ${count2}"
+ok "second run-once HTTP 200, count=${count2}"
+
+failed2=$(printf '%s' "${resp2}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+print(sum(1 for r in d.get('results',[]) if r.get('errorMessage') or r.get('reason')=='create_failed'))
+")
+[[ "${failed2}" == "0" ]] || fail "second run-once expected failed=0, got ${failed2}"
+ok "second run-once failed=0"
+
+# Verify second run still hits id=34 via standard dedup_key (after backfill)
+oor_ok2=$(printf '%s' "${resp2}" | python3 -c "
+import json,sys
+d=json.load(sys.stdin)
+hit=any(r for r in d.get('results',[]) if str(r.get('sourceRuleId'))=='1' and r.get('skipped') and str(r.get('matchedExceptionId'))=='34')
+print('YES' if hit else 'NO')
+")
+[[ "${oor_ok2}" == "YES" ]] || fail "second run-once: OUT_OF_RANGE expected to still match id=34 via dedup_key"
+ok "second run-once: id=34 matched via dedup_key (post-backfill)"
+
+# Active counts must remain at 1 each for the three rules
+oor_active=$(mysql_query "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_id=1 AND status<>'CLOSED' AND is_deleted=0;")
+to_active=$(mysql_query "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_code='G01_TEST_TIMEOUT' AND status<>'CLOSED' AND is_deleted=0;")
+sh_active=$(mysql_query "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_code='G01_TEST_SHORTAGE' AND status<>'CLOSED' AND is_deleted=0;")
+[[ "${oor_active}" == "1" ]] || fail "OUT_OF_RANGE active count expected 1, got ${oor_active}"
+[[ "${to_active}"  == "1" ]] || fail "TIMEOUT active count expected 1, got ${to_active}"
+[[ "${sh_active}"  == "1" ]] || fail "SHORTAGE active count expected 1, got ${sh_active}"
+ok "active counts: OUT_OF_RANGE=1 / TIMEOUT=1 / SHORTAGE=1 (no duplicates)"
+
+after_oor=$(mysql_query "SELECT last_detected_at FROM ado_s8_exception WHERE id=34;")
+after_to=$(mysql_query "SELECT last_detected_at FROM ado_s8_exception WHERE id=52;")
+after_sh=$(mysql_query "SELECT last_detected_at FROM ado_s8_exception WHERE id=53;")
+[[ "${after_oor}" > "${before_oor}" ]] || fail "id=34 last_detected_at did not refresh: ${before_oor} -> ${after_oor}"
+[[ "${after_to}"  > "${before_to}"  ]] || fail "id=52 last_detected_at did not refresh: ${before_to} -> ${after_to}"
+[[ "${after_sh}"  > "${before_sh}"  ]] || fail "id=53 last_detected_at did not refresh: ${before_sh} -> ${after_sh}"
+ok "last_detected_at refreshed for id=34 / 52 / 53"
+
+ok "R3-OUT_OF_RANGE regression PASSED"

+ 66 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeParams.cs

@@ -0,0 +1,66 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// R3 OUT_OF_RANGE 类规则 params_json 解析结果。internal 可见,供测试覆盖。
+/// 兼容两种协议形态:
+/// 1. 新协议:measuredValueField / lowerBound / upperBound / lowerBoundField / upperBoundField / objectCodeField / objectIdField / toleranceAbs / toleranceRatio / exceptionTypeCode
+/// 2. 旧 G01 兼容:mode=single_threshold / operator=&quot;&gt;&quot;|&quot;&gt;=&quot;|&quot;&lt;&quot;|&quot;&lt;=&quot; / threshold / valueField / objectCodeField / compatAlertRuleCode
+/// 字段缺失或非法 JSON:调用方负责降级(evaluator 静默返空命中),不在此抛出业务异常。
+/// </summary>
+internal sealed class S8OutOfRangeParams
+{
+    public string? MeasuredValueField { get; set; }
+    public string? ObjectCodeField { get; set; }
+    public string? ObjectIdField { get; set; }
+    public string? LowerBoundField { get; set; }
+    public string? UpperBoundField { get; set; }
+    public decimal? LowerBound { get; set; }
+    public decimal? UpperBound { get; set; }
+    public decimal ToleranceAbs { get; set; }
+    public decimal ToleranceRatio { get; set; }
+    public string? ExceptionTypeCode { get; set; }
+
+    /// <summary>解析 params_json;非法 JSON 抛 <see cref="JsonException"/>,由上游捕获。</summary>
+    public static S8OutOfRangeParams Parse(string json)
+    {
+        using var doc = JsonDocument.Parse(json);
+        var root = doc.RootElement;
+        var p = new S8OutOfRangeParams();
+
+        // 新协议字段
+        if (root.TryGetProperty("measuredValueField", out var v1)) p.MeasuredValueField = v1.GetString();
+        if (root.TryGetProperty("objectCodeField", out var v2)) p.ObjectCodeField = v2.GetString();
+        if (root.TryGetProperty("objectIdField", out var v3)) p.ObjectIdField = v3.GetString();
+        if (root.TryGetProperty("lowerBoundField", out var v4)) p.LowerBoundField = v4.GetString();
+        if (root.TryGetProperty("upperBoundField", out var v5)) p.UpperBoundField = v5.GetString();
+        if (root.TryGetProperty("lowerBound", out var v6) && v6.ValueKind == JsonValueKind.Number) p.LowerBound = v6.GetDecimal();
+        if (root.TryGetProperty("upperBound", out var v7) && v7.ValueKind == JsonValueKind.Number) p.UpperBound = v7.GetDecimal();
+        if (root.TryGetProperty("toleranceAbs", out var v8) && v8.ValueKind == JsonValueKind.Number) p.ToleranceAbs = v8.GetDecimal();
+        if (root.TryGetProperty("toleranceRatio", out var v9) && v9.ValueKind == JsonValueKind.Number) p.ToleranceRatio = v9.GetDecimal();
+        if (root.TryGetProperty("exceptionTypeCode", out var v10)) p.ExceptionTypeCode = v10.GetString();
+
+        // G01 兼容:valueField → measuredValueField
+        if (string.IsNullOrWhiteSpace(p.MeasuredValueField)
+            && root.TryGetProperty("valueField", out var legacyVal))
+            p.MeasuredValueField = legacyVal.GetString();
+
+        // G01 兼容:mode=single_threshold + operator + threshold → upperBound 或 lowerBound
+        if (root.TryGetProperty("mode", out var modeEl)
+            && string.Equals(modeEl.GetString(), "single_threshold", StringComparison.OrdinalIgnoreCase)
+            && root.TryGetProperty("operator", out var opEl)
+            && root.TryGetProperty("threshold", out var thrEl)
+            && thrEl.ValueKind == JsonValueKind.Number)
+        {
+            var op = opEl.GetString()?.Trim();
+            var thr = thrEl.GetDecimal();
+            // operator '>' 或 '>=' → measured 超过 threshold 命中 → 等价 upperBound=threshold
+            // operator '<' 或 '<=' → measured 低于 threshold 命中 → 等价 lowerBound=threshold
+            if (op == ">" || op == ">=") p.UpperBound ??= thr;
+            else if (op == "<" || op == "<=") p.LowerBound ??= thr;
+        }
+
+        return p;
+    }
+}

+ 224 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -0,0 +1,224 @@
+using System.Data;
+using System.Globalization;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// R3 OUT_OF_RANGE 类规则 evaluator MVP("测量值越界")。
+/// 三种最小模式:固定上限 / 固定下限 / 行内上下限;兼容 G01 single_threshold 协议。
+/// 判定(任一命中即 HIT):
+///   measured &gt; upperBound + toleranceAbs 且 (measured - upperBound) / |upperBound| &gt; toleranceRatio → ABOVE_UPPER
+///   measured &lt; lowerBound - toleranceAbs 且 (lowerBound - measured) / |lowerBound| &gt; toleranceRatio → BELOW_LOWER
+/// upperBound / lowerBound 为 0 时 ratio 检查跳过(避免除零)。
+/// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
+/// </summary>
+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<AdoS8DataSource> _dataSourceRep;
+
+    public S8OutOfRangeRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
+    {
+        _dataSourceRep = dataSourceRep;
+    }
+
+    public async Task<List<S8RuleHit>> EvaluateAsync(
+        long tenantId,
+        long factoryId,
+        AdoS8WatchRule rule,
+        IReadOnlyList<AdoS8AlertRule> alertRules,
+        CancellationToken cancellationToken = default)
+    {
+        var hits = new List<S8RuleHit>();
+
+        if (string.IsNullOrWhiteSpace(rule.Expression) || string.IsNullOrWhiteSpace(rule.ParamsJson))
+            return hits;
+
+        S8OutOfRangeParams parameters;
+        try { parameters = S8OutOfRangeParams.Parse(rule.ParamsJson!); }
+        catch { return hits; }
+
+        if (string.IsNullOrWhiteSpace(parameters.MeasuredValueField))
+            return hits;
+
+        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))
+            return hits;
+
+        DataTable table;
+        try
+        {
+            using var db = CreateSqlScope(dataSource.Endpoint!);
+            table = await db.Ado.GetDataTableAsync(rule.Expression!);
+        }
+        catch
+        {
+            return hits;
+        }
+
+        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 = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : 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
+        });
+    }
+
+    /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
+    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<string, object?>(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;
+    }
+}

+ 92 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -24,6 +24,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8ManualReportService _manualReportService;
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
     private readonly S8ShortageRuleEvaluator _shortageEvaluator;
+    private readonly S8OutOfRangeRuleEvaluator _outOfRangeEvaluator;
 
     private const string DefaultTriggerType = "VALUE_DEVIATION";
     private const string SqlDataSourceType = "SQL";
@@ -43,7 +44,8 @@ public class S8WatchSchedulerService : ITransient
         S8NotificationService notificationService,
         S8ManualReportService manualReportService,
         S8TimeoutRuleEvaluator timeoutEvaluator,
-        S8ShortageRuleEvaluator shortageEvaluator)
+        S8ShortageRuleEvaluator shortageEvaluator,
+        S8OutOfRangeRuleEvaluator outOfRangeEvaluator)
     {
         _ruleRep = ruleRep;
         _alertRuleRep = alertRuleRep;
@@ -54,15 +56,19 @@ public class S8WatchSchedulerService : ITransient
         _manualReportService = manualReportService;
         _timeoutEvaluator = timeoutEvaluator;
         _shortageEvaluator = shortageEvaluator;
+        _outOfRangeEvaluator = outOfRangeEvaluator;
     }
 
     public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
     {
+        // R3 OUT_OF_RANGE 重写后,新三类(OUT_OF_RANGE/TIMEOUT/SHORTAGE)改走 ProcessRulesByTypeAsync。
+        // 旧 AlertRule 兼容主链此处只装载 RuleType 为空/未分类的历史规则,避免新旧双跑导致重复建单。
         var watchRules = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
                 && x.FactoryId == factoryId
                 && x.Enabled
-                && x.SceneCode == S8SceneCode.S2S6Production)
+                && x.SceneCode == S8SceneCode.S2S6Production
+                && (x.RuleType == null || x.RuleType == ""))
             .ToListAsync();
 
         var deviceRules = watchRules
@@ -380,11 +386,12 @@ public class S8WatchSchedulerService : ITransient
             }
         }
 
-        // R2: TIMEOUT 路径,按 rule_type='TIMEOUT' 分派;OUT_OF_RANGE 走上方既有兼容分支不动
-        // 未知 rule_type 与 null 由 LoadExecutionRulesAsync 既有过滤(DEVICE + S2S6_PRODUCTION + AlertRule)天然隔离,
-        // 不在本路径处理;不抛出全局异常
+        // R3-OUT_OF_RANGE-REWRITE-1:三类正式 evaluator 路径
+        // 旧 AlertRule 兼容主链(上方 dedupResults 循环)已在 LoadExecutionRulesAsync 处过滤掉
+        // OUT_OF_RANGE/TIMEOUT/SHORTAGE,只保留未分类历史规则;故新旧不双跑
         results.AddRange(await ProcessTimeoutRulesAsync(tenantId, factoryId));
         results.AddRange(await ProcessShortageRulesAsync(tenantId, factoryId));
+        results.AddRange(await ProcessOutOfRangeRulesAsync(tenantId, factoryId));
 
         return results;
     }
@@ -401,6 +408,15 @@ public class S8WatchSchedulerService : ITransient
     public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId, long factoryId) =>
         ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode);
 
+    /// <summary>
+    /// R3-OUT_OF_RANGE-REWRITE-1:OUT_OF_RANGE 类规则主链。
+    /// 复用 <see cref="ProcessRulesByTypeAsync"/>,并对历史 dedup_key=NULL 的旧记录做 compat fallback:
+    /// (source_rule_id=rule.Id AND related_object_code=hit AND status!=CLOSED AND dedup_key IS NULL AND is_deleted=0)
+    /// 命中则 backfill 6 列,避免重复建单。
+    /// </summary>
+    public Task<List<S8WatchCreationResult>> ProcessOutOfRangeRulesAsync(long tenantId, long factoryId) =>
+        ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode);
+
     /// <summary>
     /// R2/R3 通用规则主链:装载 enabled WatchRule.RuleType=ruleType → evaluator → dedup_key 去重 → 建单/刷新。
     /// dedup 命中:UPDATE last_detected_at + source_payload,不重复建单;
@@ -471,6 +487,35 @@ public class S8WatchSchedulerService : ITransient
                     continue;
                 }
 
+                // R3-OUT_OF_RANGE-REWRITE-1 compat fallback:dedup_key 未命中且 ruleType=OUT_OF_RANGE 时,
+                // 尝试匹配历史 dedup_key=NULL 的旧 AlertRule 主链记录(如 id=34),命中则 backfill 6 列,避免重复建单。
+                if (string.Equals(ruleType, S8OutOfRangeRuleEvaluator.RuleTypeCode, StringComparison.OrdinalIgnoreCase))
+                {
+                    long compatId;
+                    try
+                    {
+                        compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, factoryId, rule.Id, hit.RelatedObjectCode);
+                    }
+                    catch (Exception ex)
+                    {
+                        results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
+                        continue;
+                    }
+                    if (compatId > 0)
+                    {
+                        try
+                        {
+                            await BackfillLegacyExceptionAsync(compatId, hit);
+                            results.Add(BuildSkippedDuplicate(rule, hit, compatId));
+                        }
+                        catch (Exception ex)
+                        {
+                            results.Add(BuildSkipResult(rule, "refresh_failed", ex.Message, hit));
+                        }
+                        continue;
+                    }
+                }
+
                 bool typeExists;
                 try
                 {
@@ -522,6 +567,48 @@ public class S8WatchSchedulerService : ITransient
         return ids.Count > 0 ? ids[0] : 0L;
     }
 
+    /// <summary>
+    /// R3 OUT_OF_RANGE compat fallback 查找:用 (source_rule_id, related_object_code, status!=CLOSED,
+    /// dedup_key IS NULL, is_deleted=0) 严格条件定位旧 AlertRule 主链留下的历史记录。
+    /// </summary>
+    private async Task<long> FindLegacyOutOfRangeExceptionAsync(long tenantId, long factoryId, long sourceRuleId, string relatedObjectCode)
+    {
+        if (string.IsNullOrWhiteSpace(relatedObjectCode)) return 0L;
+        var ids = await _exceptionRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && !x.IsDeleted
+                        && x.Status != "CLOSED"
+                        && x.DedupKey == null
+                        && x.SourceRuleId == sourceRuleId
+                        && x.RelatedObjectCode == relatedObjectCode)
+            .Select(x => x.Id)
+            .Take(1)
+            .ToListAsync();
+        return ids.Count > 0 ? ids[0] : 0L;
+    }
+
+    /// <summary>
+    /// R3 OUT_OF_RANGE compat fallback backfill:把历史记录的 R1 新 6 列(dedup_key/source_rule_code/
+    /// source_object_type/source_object_id/source_payload/last_detected_at)写入,并刷新 updated_at。
+    /// </summary>
+    private async Task BackfillLegacyExceptionAsync(long exceptionId, S8RuleHit hit)
+    {
+        await _exceptionRep.Context.Updateable<AdoS8Exception>()
+            .SetColumns(x => new AdoS8Exception
+            {
+                DedupKey = hit.DedupKey,
+                SourceRuleCode = hit.SourceRuleCode,
+                SourceObjectType = hit.SourceObjectType,
+                SourceObjectId = hit.SourceObjectId,
+                SourcePayload = hit.SourcePayload,
+                LastDetectedAt = hit.DetectedAt,
+                UpdatedAt = DateTime.Now
+            })
+            .Where(x => x.Id == exceptionId)
+            .ExecuteCommandAsync();
+    }
+
     private async Task RefreshDetectionAsync(long exceptionId, S8RuleHit hit)
     {
         await _exceptionRep.Context.Updateable<AdoS8Exception>()