ソースを参照

feat(s8): add timeout rule evaluator MVP

R2-TIMEOUT-EVALUATOR-1: introduce minimal IS8RuleEvaluator abstraction
and TIMEOUT class implementation. OUT_OF_RANGE keeps current compat
flow untouched (G01_TEST_WATCH regression intact).

- Service/S8/Rules/IS8RuleEvaluator.cs: interface + S8RuleHit model
- Service/S8/Rules/S8TimeoutRuleEvaluator.cs: TIMEOUT params parser +
  expression-based evaluation; supports dueAtField / statusField /
  completedStates / objectCodeField / objectIdField / graceMinutes /
  exceptionTypeCode
- S8WatchSchedulerService.CreateExceptionsAsync now calls
  ProcessTimeoutRulesAsync after the existing OUT_OF_RANGE path:
  loads enabled rule_type=TIMEOUT WatchRules, runs evaluator, dedups
  by dedup_key against open (status != CLOSED) exceptions, refreshes
  last_detected_at + source_payload on duplicates, validates
  exception_type_code against tenant=0/factory=0 baseline before
  inserting new rows.
- S8ManualReportService.CreateFromHitAsync: new path that writes the
  full set of R2 columns (dedup_key, last_detected_at, source_rule_code,
  source_object_type, source_object_id, source_rule_id,
  source_data_source_id, source_payload). recovered_at left null.

Verified end-to-end via /api/aidop/s8/watch-debug/run-once:
- run #1 → created=1 (id=52 TIMEOUT) + skipped=1 (G01 OUT_OF_RANGE
  dedup hit id=34) + failed=0
- run #2 → created=0 + skipped=2 (TIMEOUT now dedup hit id=52,
  last_detected_at refreshed) + failed=0
YY968XX 1 ヶ月 前
コミット
0bbc666bc6

+ 45 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs

@@ -0,0 +1,45 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// R2 规则 evaluator 抽象。每个 rule_type 对应一个实现,由 S8WatchSchedulerService 按 rule_type 分派。
+/// 首版仅 TIMEOUT;OUT_OF_RANGE 走 S8WatchSchedulerService 内既有兼容分支,不接入此抽象。
+/// 不做事件触发、不做 SLA 升级、不做严重度阶梯。
+/// </summary>
+public interface IS8RuleEvaluator
+{
+    /// <summary>支持的 rule_type 字符串(TIMEOUT / SHORTAGE / OUT_OF_RANGE)。</summary>
+    string RuleType { get; }
+
+    /// <summary>评估一条 WatchRule,产出 0..N 条命中。不做去重、不建单。</summary>
+    Task<List<S8RuleHit>> EvaluateAsync(
+        long tenantId,
+        long factoryId,
+        AdoS8WatchRule rule,
+        IReadOnlyList<AdoS8AlertRule> alertRules,
+        CancellationToken cancellationToken = default);
+}
+
+/// <summary>
+/// 内部命中模型。供 R2 evaluator 与 S8WatchSchedulerService 的 dedup/建单消费。
+/// 字段全部 nullable 以兼容 TIMEOUT / SHORTAGE / OUT_OF_RANGE 三类。R2 仅 TIMEOUT 路径写入。
+/// </summary>
+public sealed class S8RuleHit
+{
+    public long SourceRuleId { get; set; }
+    public string SourceRuleCode { get; set; } = string.Empty;
+    public string SourceObjectType { get; set; } = string.Empty;
+    public string SourceObjectId { get; set; } = string.Empty;
+    public string RelatedObjectCode { get; set; } = string.Empty;
+    public string ExceptionTypeCode { get; set; } = string.Empty;
+    public string SceneCode { get; set; } = string.Empty;
+    public string Severity { get; set; } = "MEDIUM";
+    public string DedupKey { get; set; } = string.Empty;
+    public string SourcePayload { get; set; } = "{}";
+    public DateTime DetectedAt { get; set; } = DateTime.Now;
+    public string Title { get; set; } = string.Empty;
+    public long DataSourceId { get; set; }
+    public long? OccurrenceDeptId { get; set; }
+    public long? ResponsibleDeptId { get; set; }
+}

+ 212 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -0,0 +1,212 @@
+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>
+/// R2 TIMEOUT 类规则 evaluator MVP。
+/// params_json 约定(首版最小集合):
+///   { dueAtField, statusField, completedStates[], objectCodeField, objectIdField, graceMinutes, exceptionTypeCode }
+/// 判定:dueAt &lt;= now - graceMinutes && status NOT IN completedStates → HIT。
+/// 不做严重度阶梯、不做 SLA 升级、不做事件触发。
+/// </summary>
+public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
+{
+    public const string RuleTypeCode = "TIMEOUT";
+    public string RuleType => RuleTypeCode;
+
+    private const string SqlDataSourceType = "SQL";
+
+    private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+
+    public S8TimeoutRuleEvaluator(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;
+
+        TimeoutParams parameters;
+        try { parameters = TimeoutParams.Parse(rule.ParamsJson!); }
+        catch { return hits; }
+
+        if (string.IsNullOrWhiteSpace(parameters.DueAtField)
+            || string.IsNullOrWhiteSpace(parameters.StatusField)
+            || 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 threshold = detectedAt.AddMinutes(-parameters.GraceMinutes);
+        var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
+            ? rule.WatchObjectType
+            : rule.SourceObjectType!;
+
+        foreach (DataRow row in table.Rows)
+        {
+            var status = ReadString(row, parameters.StatusField!) ?? string.Empty;
+            if (parameters.CompletedStates.Contains(status, StringComparer.OrdinalIgnoreCase))
+                continue;
+
+            var due = ReadDateTime(row, parameters.DueAtField!);
+            if (due == null || due > threshold) 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, due.Value, status, parameters),
+                DetectedAt = detectedAt,
+                Title = $"[超时] {sourceObjectType} {sourceObjectId} 已超期至 {due.Value:yyyy-MM-dd HH:mm:ss}(状态 {status})",
+                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-timeout-eval-{Guid.NewGuid():N}",
+            DbType = dbType,
+            ConnectionString = connectionString,
+            InitKeyType = InitKeyType.Attribute,
+            IsAutoCloseConnection = true
+        });
+    }
+
+    private 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, DateTime dueAt, string status, TimeoutParams 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["__ruleType"] = RuleTypeCode;
+        payload["__dueAt"] = dueAt;
+        payload["__status"] = status;
+        payload["__graceMinutes"] = parameters.GraceMinutes;
+        payload["__sourceObjectType"] = parameters.ObjectIdField;
+        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 DateTime? ReadDateTime(DataRow row, string columnName)
+    {
+        if (!row.Table.Columns.Contains(columnName)) return null;
+        var v = row[columnName];
+        if (v == DBNull.Value) return null;
+        if (v is DateTime dt) return dt;
+        return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), out var p) ? p : 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;
+    }
+
+    private sealed class TimeoutParams
+    {
+        public string? DueAtField { get; set; }
+        public string? StatusField { get; set; }
+        public List<string> CompletedStates { get; set; } = new();
+        public string? ObjectCodeField { get; set; }
+        public string? ObjectIdField { get; set; }
+        public int GraceMinutes { get; set; }
+        public string? ExceptionTypeCode { get; set; }
+
+        public static TimeoutParams Parse(string json)
+        {
+            using var doc = JsonDocument.Parse(json);
+            var root = doc.RootElement;
+            var p = new TimeoutParams();
+            if (root.TryGetProperty("dueAtField", out var v1)) p.DueAtField = v1.GetString();
+            if (root.TryGetProperty("statusField", out var v2)) p.StatusField = 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("graceMinutes", out var v5) && v5.ValueKind == JsonValueKind.Number) p.GraceMinutes = v5.GetInt32();
+            if (root.TryGetProperty("exceptionTypeCode", out var v6)) p.ExceptionTypeCode = v6.GetString();
+            if (root.TryGetProperty("completedStates", out var v7) && v7.ValueKind == JsonValueKind.Array)
+            {
+                foreach (var s in v7.EnumerateArray())
+                {
+                    var str = s.GetString();
+                    if (!string.IsNullOrWhiteSpace(str)) p.CompletedStates.Add(str!);
+                }
+            }
+            return p;
+        }
+    }
+}

+ 71 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -3,6 +3,7 @@ using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
 using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8;
 
@@ -206,6 +207,76 @@ public class S8ManualReportService : ITransient
         return entity;
     }
 
+    /// <summary>
+    /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。
+    /// 与 <see cref="CreateFromWatchAsync"/> 并列:复用同一仓储 / 事务 / 时间线 ActionCode;
+    /// 差异点:消费 <see cref="S8RuleHit"/> 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt /
+    /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带,
+    /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。
+    /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。
+    /// </summary>
+    public async Task<AdoS8Exception> CreateFromHitAsync(S8RuleHit hit)
+    {
+        if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
+            throw new S8BizException("自动建单缺失追溯键");
+
+        var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
+        var entity = new AdoS8Exception
+        {
+            // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。
+            TenantId = 1,
+            FactoryId = 1,
+            ExceptionCode = code,
+            Title = string.IsNullOrWhiteSpace(hit.Title)
+                ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
+                : hit.Title,
+            Description = null,
+            SceneCode = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2S6Production : hit.SceneCode,
+            SourceType = "AUTO_WATCH",
+            Status = "NEW",
+            Severity = string.IsNullOrWhiteSpace(hit.Severity) ? "MEDIUM" : hit.Severity,
+            PriorityScore = 0,
+            PriorityLevel = "P3",
+            OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
+            ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
+            ReporterId = null,
+            CreatedAt = DateTime.Now,
+            IsDeleted = false,
+            ExceptionTypeCode = hit.ExceptionTypeCode,
+            ModuleCode = null,
+            ProcessNodeCode = null,
+            SourceRuleId = hit.SourceRuleId,
+            SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId,
+            SourcePayload = hit.SourcePayload,
+            RelatedObjectCode = hit.RelatedObjectCode,
+            // R2 新列回填
+            DedupKey = hit.DedupKey,
+            LastDetectedAt = hit.DetectedAt,
+            RecoveredAt = null,
+            SourceRuleCode = hit.SourceRuleCode,
+            SourceObjectType = hit.SourceObjectType,
+            SourceObjectId = hit.SourceObjectId
+        };
+
+        await _rep.AsTenant().UseTranAsync(async () =>
+        {
+            entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
+            await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
+            {
+                ExceptionId = entity.Id,
+                ActionCode = "CREATE",
+                ActionLabel = "创建",
+                FromStatus = null,
+                ToStatus = "NEW",
+                OperatorId = null,
+                ActionRemark = "自动建单(R2)",
+                CreatedAt = DateTime.Now
+            });
+        }, ex => throw ex);
+
+        return entity;
+    }
+
     public async Task<AdoS8Exception?> GetAsync(long id) =>
         await _rep.GetByIdAsync(id);
 

+ 210 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using SqlSugar;
 using System.Data;
 using System.Globalization;
@@ -18,8 +19,10 @@ public class S8WatchSchedulerService : ITransient
     private readonly SqlSugarRepository<AdoS8AlertRule> _alertRuleRep;
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
+    private readonly SqlSugarRepository<AdoS8ExceptionType> _exceptionTypeRep;
     private readonly S8NotificationService _notificationService;
     private readonly S8ManualReportService _manualReportService;
+    private readonly S8TimeoutRuleEvaluator _timeoutEvaluator;
 
     private const string DefaultTriggerType = "VALUE_DEVIATION";
     private const string SqlDataSourceType = "SQL";
@@ -35,15 +38,19 @@ public class S8WatchSchedulerService : ITransient
         SqlSugarRepository<AdoS8AlertRule> alertRuleRep,
         SqlSugarRepository<AdoS8DataSource> dataSourceRep,
         SqlSugarRepository<AdoS8Exception> exceptionRep,
+        SqlSugarRepository<AdoS8ExceptionType> exceptionTypeRep,
         S8NotificationService notificationService,
-        S8ManualReportService manualReportService)
+        S8ManualReportService manualReportService,
+        S8TimeoutRuleEvaluator timeoutEvaluator)
     {
         _ruleRep = ruleRep;
         _alertRuleRep = alertRuleRep;
         _dataSourceRep = dataSourceRep;
         _exceptionRep = exceptionRep;
+        _exceptionTypeRep = exceptionTypeRep;
         _notificationService = notificationService;
         _manualReportService = manualReportService;
+        _timeoutEvaluator = timeoutEvaluator;
     }
 
     public async Task<List<S8WatchExecutionRule>> LoadExecutionRulesAsync(long tenantId, long factoryId)
@@ -370,9 +377,211 @@ 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);
+
+        return results;
+    }
+
+    /// <summary>
+    /// R2 TIMEOUT 类规则主链:装载 enabled WatchRule.RuleType='TIMEOUT' → 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)
+    {
+        var results = new List<S8WatchCreationResult>();
+
+        var timeoutRules = await _ruleRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && x.Enabled
+                        && x.RuleType == S8TimeoutRuleEvaluator.RuleTypeCode)
+            .ToListAsync();
+        if (timeoutRules.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))
+        {
+            List<S8RuleHit> hits;
+            try
+            {
+                hits = await _timeoutEvaluator.EvaluateAsync(tenantId, factoryId, rule, alertRules);
+            }
+            catch (Exception ex)
+            {
+                results.Add(BuildSkipResult(rule, "evaluate_failed", ex.Message));
+                continue;
+            }
+
+            foreach (var hit in hits)
+            {
+                if (string.IsNullOrWhiteSpace(hit.DedupKey))
+                {
+                    results.Add(BuildSkipResult(rule, "missing_dedup_key", null, hit));
+                    continue;
+                }
+
+                long matchedId;
+                try
+                {
+                    matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, factoryId, hit.DedupKey);
+                }
+                catch (Exception ex)
+                {
+                    results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
+                    continue;
+                }
+
+                if (matchedId > 0)
+                {
+                    try
+                    {
+                        await RefreshDetectionAsync(matchedId, hit);
+                        results.Add(BuildSkippedDuplicate(rule, hit, matchedId));
+                    }
+                    catch (Exception ex)
+                    {
+                        results.Add(BuildSkipResult(rule, "refresh_failed", ex.Message, hit));
+                    }
+                    continue;
+                }
+
+                bool typeExists;
+                try
+                {
+                    typeExists = await _exceptionTypeRep.AsQueryable()
+                        .Where(t => t.TypeCode == hit.ExceptionTypeCode
+                                    && (t.TenantId == 0 || t.TenantId == tenantId)
+                                    && (t.FactoryId == 0 || t.FactoryId == factoryId)
+                                    && t.Enabled)
+                        .AnyAsync();
+                }
+                catch (Exception ex)
+                {
+                    results.Add(BuildSkipResult(rule, "query_failed", ex.Message, hit));
+                    continue;
+                }
+
+                if (!typeExists)
+                {
+                    results.Add(BuildSkipResult(rule, "exception_type_missing", null, hit));
+                    continue;
+                }
+
+                try
+                {
+                    var entity = await _manualReportService.CreateFromHitAsync(hit);
+                    results.Add(BuildCreatedResult(rule, hit, entity.Id));
+                }
+                catch (Exception ex)
+                {
+                    results.Add(BuildSkipResult(rule, "create_failed", ex.Message, hit));
+                }
+            }
+        }
+
         return results;
     }
 
+    private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, long factoryId, string dedupKey)
+    {
+        var ids = await _exceptionRep.AsQueryable()
+            .Where(x => x.TenantId == tenantId
+                        && x.FactoryId == factoryId
+                        && !x.IsDeleted
+                        && x.Status != "CLOSED"
+                        && x.DedupKey == dedupKey)
+            .Select(x => x.Id)
+            .Take(1)
+            .ToListAsync();
+        return ids.Count > 0 ? ids[0] : 0L;
+    }
+
+    private async Task RefreshDetectionAsync(long exceptionId, S8RuleHit hit)
+    {
+        await _exceptionRep.Context.Updateable<AdoS8Exception>()
+            .SetColumns(x => new AdoS8Exception
+            {
+                LastDetectedAt = hit.DetectedAt,
+                SourcePayload = hit.SourcePayload,
+                UpdatedAt = DateTime.Now
+            })
+            .Where(x => x.Id == exceptionId)
+            .ExecuteCommandAsync();
+    }
+
+    private static S8WatchCreationResult BuildCreatedResult(AdoS8WatchRule rule, S8RuleHit hit, long exceptionId) =>
+        new()
+        {
+            DedupResult = new S8WatchDedupResult
+            {
+                Hit = ToWatchHit(rule, hit),
+                CanCreate = true,
+                MatchedExceptionId = null,
+                Reason = "no_pending"
+            },
+            Created = true,
+            Skipped = false,
+            CreatedExceptionId = exceptionId,
+            Reason = "auto_created",
+            ErrorMessage = null
+        };
+
+    private static S8WatchCreationResult BuildSkippedDuplicate(AdoS8WatchRule rule, S8RuleHit hit, long matchedId) =>
+        new()
+        {
+            DedupResult = new S8WatchDedupResult
+            {
+                Hit = ToWatchHit(rule, hit),
+                CanCreate = false,
+                MatchedExceptionId = matchedId,
+                Reason = "duplicate_pending"
+            },
+            Created = false,
+            Skipped = true,
+            CreatedExceptionId = null,
+            Reason = "duplicate_pending",
+            ErrorMessage = null
+        };
+
+    private static S8WatchCreationResult BuildSkipResult(AdoS8WatchRule rule, string reason, string? error, S8RuleHit? hit = null) =>
+        new()
+        {
+            DedupResult = new S8WatchDedupResult
+            {
+                Hit = hit != null ? ToWatchHit(rule, hit) : new S8WatchHitResult { SourceRuleId = rule.Id, SourceRuleCode = rule.RuleCode },
+                CanCreate = false,
+                MatchedExceptionId = null,
+                Reason = reason
+            },
+            Created = false,
+            Skipped = true,
+            CreatedExceptionId = null,
+            Reason = reason,
+            ErrorMessage = error
+        };
+
+    private static S8WatchHitResult ToWatchHit(AdoS8WatchRule rule, S8RuleHit hit) => new()
+    {
+        SourceRuleId = hit.SourceRuleId == 0 ? rule.Id : hit.SourceRuleId,
+        SourceRuleCode = string.IsNullOrEmpty(hit.SourceRuleCode) ? rule.RuleCode : hit.SourceRuleCode,
+        DataSourceId = hit.DataSourceId,
+        RelatedObjectCode = hit.RelatedObjectCode,
+        Severity = hit.Severity,
+        OccurrenceDeptId = hit.OccurrenceDeptId,
+        ResponsibleDeptId = hit.ResponsibleDeptId,
+        SourcePayload = hit.SourcePayload
+    };
+
     /// <summary>
     /// 单次轮询入口。当前仅完成规则读取与组装,返回可执行规则数量,不做实际数据采集。
     /// </summary>