Просмотр исходного кода

feat(s8): add shortage rule evaluator MVP

R3-SHORTAGE-EVALUATOR-1: implement SHORTAGE class on the same evaluator
abstraction; refactor the per-rule processing loop to a generic helper
shared by TIMEOUT and SHORTAGE; close the watch-debug endpoint default.

Evaluator:
- Service/S8/Rules/S8ShortageParams.cs: internal params parser
  (targetQtyField / actualQtyField / objectCodeField / objectIdField /
   toleranceAbs / toleranceRatio / exceptionTypeCode).
- Service/S8/Rules/S8ShortageRuleEvaluator.cs: SHORTAGE evaluator.
  Hit when target > 0 and actual present and shortage > toleranceAbs and
  shortage / target > toleranceRatio. Payload carries target_qty /
  actual_qty / shortage_qty / shortage_ratio plus __ruleType /
  __sourceObjectType / __sourceObjectId / __exceptionTypeCode.

Scheduler:
- S8WatchSchedulerService now injects S8ShortageRuleEvaluator and
  exposes ProcessShortageRulesAsync (thin wrapper) alongside
  ProcessTimeoutRulesAsync, both delegating to the new private
  ProcessRulesByTypeAsync(IS8RuleEvaluator, ruleType) helper.
- CreateExceptionsAsync runs OUT_OF_RANGE (legacy compat) → TIMEOUT
  → SHORTAGE; OUT_OF_RANGE flow is unchanged.

DebugEndpointEnabled safety closure (production-safe default):
- Configuration/WatchScheduler.json default flipped from true to false.
- Configuration/WatchScheduler.Development.json overrides true under
  ASPNETCORE_ENVIRONMENT=Development; Furion ConfigurationScanDirectories
  picks up the env-suffixed file. Production deployments without the
  Development override return 404 from /api/aidop/s8/watch-debug/run-once.

Verified end-to-end (run-once x2):
- run #1 → count=3: OUT_OF_RANGE skipped dup id=34, TIMEOUT skipped dup
  id=52, SHORTAGE created id=53.
- run #2 → count=3: all three skipped duplicate_pending; SHORTAGE
  last_detected_at refreshed; failed=0.
- DB: id=53 source_object_type=MATERIAL / dedup_key=
  T1:F1:RG01_TEST_SHORTAGE:MATERIAL:MAT-SHORT-01 / payload contains
  target=100 / actual=70 / shortage=30 / ratio=0.3.
- scripts/s8/r3-shortage-regression.sh: 11/11 PASS.
YY968XX 1 месяц назад
Родитель
Сommit
1d94443b58

+ 122 - 0
scripts/s8/r3-shortage-regression.sh

@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# R3-SHORTAGE-EVALUATOR-1 regression script (dev/test only, aidopdev).
+#
+# Drives the safe debug endpoint /api/aidop/s8/watch-debug/run-once twice and
+# verifies SHORTAGE end-to-end while ensuring TIMEOUT and OUT_OF_RANGE compat
+# paths do not regress:
+#   1. only ONE active (status != CLOSED) exception per dedup_key for SHORTAGE;
+#   2. last_detected_at refreshed between runs;
+#   3. source_payload contains target_qty / actual_qty / shortage_qty /
+#      shortage_ratio / __ruleType / __sourceObjectType / __sourceObjectId /
+#      __exceptionTypeCode metadata;
+#   4. TIMEOUT G01 still duplicate-skip against id=52;
+#   5. OUT_OF_RANGE G01 still duplicate-skip against id=34;
+#   6. baseline 13 unchanged.
+#
+# Requires the same prerequisites as r2-timeout-regression.sh (mysql client,
+# python3, aidopdev backend on http://localhost:5005, dev override of
+# WatchScheduler:DebugEndpointEnabled, populated Playwright storage state).
+
+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 15 -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}"
+}
+
+precheck=$(mysql_query "SELECT COUNT(*) FROM ado_s8_watch_rule WHERE rule_code='G01_TEST_SHORTAGE' AND enabled=1;")
+[[ "${precheck}" == "1" ]] || fail "G01_TEST_SHORTAGE not present or disabled (got '${precheck}')"
+ok "G01_TEST_SHORTAGE enabled"
+
+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"
+
+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}"
+
+# OUT_OF_RANGE compat
+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 G01 regression: expected skipped duplicate id=34"
+ok "OUT_OF_RANGE G01 compat path: skipped duplicate id=34"
+
+# TIMEOUT compat
+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 G01 regression: expected skipped duplicate id=52"
+ok "TIMEOUT G01: skipped duplicate id=52"
+
+before_ts=$(mysql_query "SELECT IFNULL(MAX(last_detected_at), '1970-01-01') FROM ado_s8_exception WHERE source_rule_code='G01_TEST_SHORTAGE' AND status<>'CLOSED' AND is_deleted=0;")
+ok "pre-second-run SHORTAGE last_detected_at=${before_ts}"
+
+sleep 1
+resp2="$(run_once)"
+count2=$(printf '%s' "${resp2}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('count',0))")
+ok "second run-once HTTP 200, count=${count2}"
+
+active_count=$(mysql_query "SELECT COUNT(*) FROM ado_s8_exception WHERE source_rule_code='G01_TEST_SHORTAGE' AND status<>'CLOSED' AND is_deleted=0;")
+[[ "${active_count}" == "1" ]] || fail "expected exactly 1 active SHORTAGE exception, got ${active_count}"
+ok "active SHORTAGE exception count = 1"
+
+after_ts=$(mysql_query "SELECT MAX(last_detected_at) FROM ado_s8_exception WHERE source_rule_code='G01_TEST_SHORTAGE' AND status<>'CLOSED' AND is_deleted=0;")
+[[ "${after_ts}" > "${before_ts}" ]] || fail "SHORTAGE last_detected_at did not refresh"
+ok "SHORTAGE last_detected_at refreshed: ${before_ts} -> ${after_ts}"
+
+payload=$(mysql_query "SELECT source_payload FROM ado_s8_exception WHERE source_rule_code='G01_TEST_SHORTAGE' AND status<>'CLOSED' AND is_deleted=0 ORDER BY id DESC LIMIT 1;")
+for field in target_qty actual_qty shortage_qty shortage_ratio __ruleType __sourceObjectType __sourceObjectId __exceptionTypeCode; do
+  echo "${payload}" | grep -q "${field}" || fail "source_payload missing ${field}"
+done
+ok "SHORTAGE source_payload contains target/actual/shortage/ratio + 4 metadata fields"
+
+ok "R3-SHORTAGE regression PASSED"

+ 9 - 0
server/Admin.NET.Application/Configuration/WatchScheduler.Development.json

@@ -0,0 +1,9 @@
+{
+  "$schema": "https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json",
+
+  // dev/test 覆写:仅在 ASPNETCORE_ENVIRONMENT=Development 时启用调试入口。
+  // 不被部署到生产环境的发布产物中(生产仍走 WatchScheduler.json 的默认 false)。
+  "WatchScheduler": {
+    "DebugEndpointEnabled": true
+  }
+}

+ 2 - 2
server/Admin.NET.Application/Configuration/WatchScheduler.json

@@ -6,7 +6,7 @@
   // 与 Admin.NET 作业管理(Enabled)等锚点上,不在本文件承载。
   "WatchScheduler": {
     // 调试入口 POST /api/aidop/s8/watch-debug/run-once 启用开关。
-    // 开发 / 测试默认 true;正式环境应覆写为 false。
-    "DebugEndpointEnabled": true
+    // 默认 false(生产安全);dev/test 通过 appsettings.Development.json 覆写为 true。
+    "DebugEndpointEnabled": false
   }
 }

+ 1 - 1
server/Admin.NET.Web.Entry/appsettings.Development.json

@@ -2,4 +2,4 @@
   "$schema": "https://gitee.com/dotnetchina/Furion/raw/v4/schemas/v4/furion-schema.json",
 
   "ConfigurationScanDirectories": [ "Configuration", "" ]
-}
+}

+ 34 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageParams.cs

@@ -0,0 +1,34 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// R3 SHORTAGE 类规则 params_json 解析结果。internal 可见,供测试覆盖。
+/// 字段缺失或非法 JSON:调用方负责降级(evaluator 静默返空命中),不在此抛出业务异常。
+/// </summary>
+internal sealed class S8ShortageParams
+{
+    public string? TargetQtyField { get; set; }
+    public string? ActualQtyField { get; set; }
+    public string? ObjectCodeField { get; set; }
+    public string? ObjectIdField { 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 S8ShortageParams Parse(string json)
+    {
+        using var doc = JsonDocument.Parse(json);
+        var root = doc.RootElement;
+        var p = new S8ShortageParams();
+        if (root.TryGetProperty("targetQtyField", out var v1)) p.TargetQtyField = v1.GetString();
+        if (root.TryGetProperty("actualQtyField", out var v2)) p.ActualQtyField = v2.GetString();
+        if (root.TryGetProperty("objectCodeField", out var v3)) p.ObjectCodeField = v3.GetString();
+        if (root.TryGetProperty("objectIdField", out var v4)) p.ObjectIdField = v4.GetString();
+        if (root.TryGetProperty("toleranceAbs", out var v5) && v5.ValueKind == JsonValueKind.Number) p.ToleranceAbs = v5.GetDecimal();
+        if (root.TryGetProperty("toleranceRatio", out var v6) && v6.ValueKind == JsonValueKind.Number) p.ToleranceRatio = v6.GetDecimal();
+        if (root.TryGetProperty("exceptionTypeCode", out var v7)) p.ExceptionTypeCode = v7.GetString();
+        return p;
+    }
+}

+ 193 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -0,0 +1,193 @@
+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 SHORTAGE 类规则 evaluator MVP("实际数量未达目标")。
+/// params_json 约定(首版最小集合):
+///   { targetQtyField, actualQtyField, objectCodeField, objectIdField,
+///     toleranceAbs, toleranceRatio, exceptionTypeCode }
+/// 判定:
+///   shortage = target - actual;
+///   target &lt;= 0 不命中(防除零 / 脏数据);
+///   actual 解析失败不命中(保守,避免误建单);
+///   shortage &gt; toleranceAbs 且 shortage / target &gt; toleranceRatio → HIT。
+/// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
+/// </summary>
+public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
+{
+    public const string RuleTypeCode = "SHORTAGE";
+    public string RuleType => RuleTypeCode;
+
+    private const string SqlDataSourceType = "SQL";
+
+    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+
+    public S8ShortageRuleEvaluator(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;
+
+        S8ShortageParams parameters;
+        try { parameters = S8ShortageParams.Parse(rule.ParamsJson!); }
+        catch { return hits; }
+
+        if (string.IsNullOrWhiteSpace(parameters.TargetQtyField)
+            || string.IsNullOrWhiteSpace(parameters.ActualQtyField)
+            || string.IsNullOrWhiteSpace(parameters.ExceptionTypeCode))
+            return hits;
+
+        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 target = ReadDecimal(row, parameters.TargetQtyField!);
+            var actual = ReadDecimal(row, parameters.ActualQtyField!);
+
+            if (target == null || target.Value <= 0m) continue;     // 防除零 / 脏数据
+            if (actual == null) continue;                            // 保守:缺 actual 不建单
+
+            var shortage = target.Value - actual.Value;
+            if (shortage <= parameters.ToleranceAbs) continue;
+
+            var ratio = shortage / target.Value;
+            if (ratio <= parameters.ToleranceRatio) 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 = parameters.ExceptionTypeCode!,
+                SceneCode = rule.SceneCode,
+                Severity = string.IsNullOrWhiteSpace(rule.Severity) ? "MEDIUM" : rule.Severity,
+                DedupKey = dedupKey,
+                SourcePayload = BuildPayload(row, sourceObjectType, sourceObjectId, target.Value, actual.Value, shortage, ratio, parameters),
+                DetectedAt = detectedAt,
+                Title = $"[数量不足] {sourceObjectType} {sourceObjectId} 缺口 {shortage:0.##}(目标 {target.Value:0.##} / 实际 {actual.Value:0.##})",
+                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-shortage-eval-{Guid.NewGuid():N}",
+            DbType = dbType,
+            ConnectionString = connectionString,
+            InitKeyType = InitKeyType.Attribute,
+            IsAutoCloseConnection = true
+        });
+    }
+
+    /// <summary>构造 R3 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</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 BuildPayload(DataRow row, string sourceObjectType, string sourceObjectId,
+        decimal target, decimal actual, decimal shortage, decimal ratio, S8ShortageParams parameters)
+    {
+        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["target_qty"] = target;
+        payload["actual_qty"] = actual;
+        payload["shortage_qty"] = shortage;
+        payload["shortage_ratio"] = ratio;
+        payload["__ruleType"] = RuleTypeCode;
+        payload["__sourceObjectType"] = sourceObjectType;
+        payload["__sourceObjectId"] = sourceObjectId;
+        payload["__exceptionTypeCode"] = parameters.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;
+    }
+}

+ 26 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -23,6 +23,7 @@ public class S8WatchSchedulerService : ITransient
     private readonly S8NotificationService _notificationService;
     private readonly S8ManualReportService _manualReportService;
     private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
+    private readonly S8ShortageRuleEvaluator _shortageEvaluator;
 
     private const string DefaultTriggerType = "VALUE_DEVIATION";
     private const string SqlDataSourceType = "SQL";
@@ -41,7 +42,8 @@ public class S8WatchSchedulerService : ITransient
         SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         S8NotificationService notificationService,
         S8ManualReportService manualReportService,
-        S8TimeoutRuleEvaluator timeoutEvaluator)
+        S8TimeoutRuleEvaluator timeoutEvaluator,
+        S8ShortageRuleEvaluator shortageEvaluator)
     {
         _ruleRep = ruleRep;
         _alertRuleRep = alertRuleRep;
@@ -51,6 +53,7 @@ public class S8WatchSchedulerService : ITransient
         _notificationService = notificationService;
         _manualReportService = manualReportService;
         _timeoutEvaluator = timeoutEvaluator;
+        _shortageEvaluator = shortageEvaluator;
     }
 
     public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
@@ -380,41 +383,54 @@ public class S8WatchSchedulerService : ITransient
         // R2: TIMEOUT 路径,按 rule_type='TIMEOUT' 分派;OUT_OF_RANGE 走上方既有兼容分支不动。
         // 未知 rule_type 与 null 由 LoadExecutionRulesAsync 既有过滤(DEVICE + S2S6_PRODUCTION + AlertRule)天然隔离,
         // 不在本路径处理;不抛出全局异常。
-        var timeoutResults = await ProcessTimeoutRulesAsync(tenantId, factoryId);
-        results.AddRange(timeoutResults);
+        results.AddRange(await ProcessTimeoutRulesAsync(tenantId, factoryId));
+        results.AddRange(await ProcessShortageRulesAsync(tenantId, factoryId));
 
         return results;
     }
 
     /// <summary>
-    /// R2 TIMEOUT 类规则主链:装载 enabled WatchRule.RuleType='TIMEOUT' → evaluator → dedup_key 去重 → 建单/刷新。
+    /// R2 TIMEOUT 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。
+    /// </summary>
+    public Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId, long factoryId) =>
+        ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode);
+
+    /// <summary>
+    /// R3 SHORTAGE 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。
+    /// </summary>
+    public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId, long factoryId) =>
+        ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode);
+
+    /// <summary>
+    /// R2/R3 通用规则主链:装载 enabled WatchRule.RuleType=ruleType → evaluator → dedup_key 去重 → 建单/刷新。
     /// dedup 命中:UPDATE last_detected_at + source_payload,不重复建单;
     /// dedup 未命中:校验 ExceptionTypeCode 是否在 baseline(tenant=0/factory=0 全局或本租户工厂),缺则跳过;
     /// 通过 → S8ManualReportService.CreateFromHitAsync 落标准 AdoS8Exception,新列全部回填。
     /// 不做 SLA 升级、不做事件触发、不做 RecoveredAt。
     /// </summary>
-    public async Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId, long factoryId)
+    private async Task<List<S8WatchCreationResult>> ProcessRulesByTypeAsync(
+        long tenantId, long factoryId, IS8RuleEvaluator evaluator, string ruleType)
     {
         var results = new List<S8WatchCreationResult>();
 
-        var timeoutRules = await _ruleRep.AsQueryable()
+        var rules = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
                         && x.FactoryId == factoryId
                         && x.Enabled
-                        && x.RuleType == S8TimeoutRuleEvaluator.RuleTypeCode)
+                        && x.RuleType == ruleType)
             .ToListAsync();
-        if (timeoutRules.Count == 0) return results;
+        if (rules.Count == 0) return results;
 
         var alertRules = (await _alertRuleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
             .ToListAsync()).AsReadOnly();
 
-        foreach (var rule in timeoutRules.OrderBy(x => x.Id))
+        foreach (var rule in rules.OrderBy(x => x.Id))
         {
             List<S8RuleHit> hits;
             try
             {
-                hits = await _timeoutEvaluator.EvaluateAsync(tenantId, factoryId, rule, alertRules);
+                hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule, alertRules);
             }
             catch (Exception ex)
             {