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

feat(s8): compute timeout from sla deadline

YY968XX 1 неделя назад
Родитель
Сommit
f8eae3f0da

+ 12 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8DashboardService.cs

@@ -45,8 +45,10 @@ public class S8DashboardService : ITransient
         var total      = await q.CountAsync();
         var pending    = await q.CountAsync(x => x.Status == "NEW" || x.Status == "ASSIGNED" || x.Status == "IN_PROGRESS" || x.Status == "PENDING_VERIFICATION" || x.Status == "REJECTED");
         var inProgress = await q.CountAsync(x => x.Status == "IN_PROGRESS");
-        // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-1):timeout 统计排除 CLOSED,与 S8MonitoringService 口径一致。
-        var timeout    = await q.CountAsync(x => x.TimeoutFlag && x.Status != "CLOSED");
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):当前超时改为运行时计算 = sla_deadline IS NOT NULL AND sla_deadline < now AND status NOT IN ('CLOSED','RECOVERED')。
+        // timeout_flag 已降级 legacy;本批不清理历史 timeout_flag。
+        var nowForTimeout = DateTime.Now;
+        var timeout    = await q.CountAsync(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED");
         var closed     = await q.CountAsync(x => x.Status == "CLOSED");
         var todayNew   = await q.CountAsync(x => x.CreatedAt >= DateTime.Today);
         // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:critical 字段名保留以维持外部 KPI;语义切为「严重」(SERIOUS)。
@@ -178,11 +180,13 @@ public class S8DashboardService : ITransient
     {
         var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" };
 
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):投影 SlaDeadline 替代 TimeoutFlag;timeout 由内存计算(与 GetSummaryAsync 一致 now)。
         var list = await _rep.AsQueryable()
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
             .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
-            .Select(x => new { x.ResponsibleDeptId, x.Status, x.TimeoutFlag })
+            .Select(x => new { x.ResponsibleDeptId, x.Status, x.SlaDeadline })
             .ToListAsync();
+        var nowForTimeout = DateTime.Now;
 
         var deptIds = list.Select(x => x.ResponsibleDeptId).Distinct().ToList();
         // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):部门水合加 factory_ref_id 约束。
@@ -200,8 +204,8 @@ public class S8DashboardService : ITransient
                 deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
                 pending    = g.Count(x => pendingStatuses.Contains(x.Status)),
                 inProgress = g.Count(x => x.Status == "IN_PROGRESS"),
-                // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-1):timeout 排除 CLOSED,与 GetSummaryAsync 口径一致
-                timeout    = g.Count(x => x.TimeoutFlag && x.Status != "CLOSED"),
+                // S8-SLA-TIMEOUT-RUNTIME-1:当前超时 = sla_deadline 在 now 之前 AND 未 CLOSED/RECOVERED
+                timeout    = g.Count(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED"),
                 total      = g.Count(),
             })
             .OrderByDescending(x => x.pending)
@@ -289,10 +293,12 @@ public class S8DashboardService : ITransient
         var q = _rep.AsQueryable()
             .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
             .Where(x => S8ModuleCode.All.Contains(x.ModuleCode));
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):timeout 模式按 sla_deadline 在线计算筛 + 升序。
+        var nowForTimeout = DateTime.Now;
         var ordered = mode switch
         {
             "high-priority" => q.OrderBy(x => x.PriorityScore, OrderByType.Desc),
-            "timeout"       => q.Where(x => x.TimeoutFlag).OrderBy(x => x.SlaDeadline),
+            "timeout"       => q.Where(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED").OrderBy(x => x.SlaDeadline),
             _               => q.OrderBy(x => x.CreatedAt, OrderByType.Desc),
         };
         return await ordered.Take(20).ToListAsync();

+ 12 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ExceptionService.cs

@@ -32,6 +32,8 @@ public class S8ExceptionService : ITransient
         var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS", "PENDING_VERIFICATION" };
 
         var includeUnclassified = q.IncludeUnclassified == true;
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):方法入口取一次 now,供 q.TimeoutFlag 筛选 + 投影写 TimeoutFlag 共用,避免分页前后不一致。
+        var timeoutNow = DateTime.Now;
 
         var query = _rep.Context.Queryable<AdoS8Exception>()
             .LeftJoin<AdoS8SceneConfig>((e, sc) =>
@@ -53,7 +55,10 @@ public class S8ExceptionService : ITransient
             // 异常列表页不传该参数,行为保持不变(仍可见 NULL module 的 legacy 行)。
             .WhereIF(q.OnlyS1S7Modules == true, (e, sc, wr) => S8ModuleCode.All.Contains(e.ModuleCode))
             .WhereIF(q.DeptId.HasValue, (e, sc, wr) => e.ResponsibleDeptId == q.DeptId!.Value || e.OccurrenceDeptId == q.DeptId!.Value)
-            .WhereIF(q.TimeoutFlag.HasValue, (e, sc, wr) => e.TimeoutFlag == q.TimeoutFlag!.Value)
+            // S8-SLA-TIMEOUT-RUNTIME-1(P3):q.TimeoutFlag 由 timeout_flag 字段筛选切到 sla_deadline + status 在线计算。
+            // q.TimeoutFlag=true → 当前超时;q.TimeoutFlag=false → 未当前超时(含未配 SLA / 已关闭 / 已恢复)。
+            .WhereIF(q.TimeoutFlag == true, (e, sc, wr) => e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED")
+            .WhereIF(q.TimeoutFlag == false, (e, sc, wr) => !(e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED"))
             .WhereIF(q.BeginTime.HasValue, (e, sc, wr) => e.CreatedAt >= q.BeginTime!.Value)
             .WhereIF(q.EndTime.HasValue, (e, sc, wr) => e.CreatedAt <= q.EndTime!.Value)
             .WhereIF(!string.IsNullOrWhiteSpace(q.ProcessNodeCode), (e, sc, wr) => e.ProcessNodeCode == q.ProcessNodeCode)
@@ -84,7 +89,8 @@ public class S8ExceptionService : ITransient
                 OccurrenceDeptId = e.OccurrenceDeptId,
                 AssigneeId = e.AssigneeId,
                 SlaDeadline = e.SlaDeadline,
-                TimeoutFlag = e.TimeoutFlag,
+                // S8-SLA-TIMEOUT-RUNTIME-1(P3):TimeoutFlag 展示字段 = 在线计算,与 dashboard / monitoring 同口径。
+                TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
                 CreatedAt = e.CreatedAt,
                 ClosedAt = e.ClosedAt,
                 ExceptionTypeCode = e.ExceptionTypeCode,
@@ -141,6 +147,8 @@ public class S8ExceptionService : ITransient
 
     public async Task<AdoS8ExceptionDetailDto?> GetDetailAsync(long id, long tenantId, long factoryId)
     {
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情 TimeoutFlag 与 list 同口径,运行时计算。
+        var timeoutNow = DateTime.Now;
         var rows = await _rep.Context.Queryable<AdoS8Exception>()
             .LeftJoin<AdoS8SceneConfig>((e, sc) =>
                 e.TenantId == sc.TenantId && e.FactoryId == sc.FactoryId && e.SceneCode == sc.SceneCode)
@@ -168,7 +176,8 @@ public class S8ExceptionService : ITransient
                 AssigneeId = e.AssigneeId,
                 ReporterId = e.ReporterId,
                 SlaDeadline = e.SlaDeadline,
-                TimeoutFlag = e.TimeoutFlag,
+                // S8-SLA-TIMEOUT-RUNTIME-1(P3):详情展示 TimeoutFlag = 在线计算。
+                TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
                 CreatedAt = e.CreatedAt,
                 ClosedAt = e.ClosedAt,
                 AssignedAt = e.AssignedAt,

+ 40 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -109,6 +109,26 @@ public class S8ManualReportService : ITransient
         _logger = logger;
     }
 
+    // S8-SLA-TIMEOUT-RUNTIME-1(P3):按 exception_type.sla_minutes 计算 sla_deadline。
+    // typeCode 空 / type 缺失 / sla_minutes <= 0 → 返回 null(不阻断建单,仅 LogWarning)。
+    // 不写 timeout_flag;timeout_flag 已降级为 legacy 字段,当前超时由读端基于 sla_deadline + status 在线计算。
+    private async Task<DateTime?> ResolveSlaDeadlineAsync(string? exceptionTypeCode, DateTime createdAt)
+    {
+        if (string.IsNullOrWhiteSpace(exceptionTypeCode)) return null;
+        var slaMinutes = await _typeRep.AsQueryable().ClearFilter()
+            .Where(t => t.TypeCode == exceptionTypeCode)
+            .OrderByDescending(t => t.FactoryId)
+            .Select(t => (int?)t.SlaMinutes)
+            .FirstAsync();
+        if (slaMinutes == null)
+        {
+            _logger.LogWarning("s8_sla_type_not_found exceptionTypeCode={TypeCode}", exceptionTypeCode);
+            return null;
+        }
+        if (slaMinutes.Value <= 0) return null;
+        return createdAt.AddMinutes(slaMinutes.Value);
+    }
+
     // S8-AUTO-WATCH-DEPT-RESOLVE-1(P2):自动建单部门解析顺序 hit → watch_rule.params_json default → 未归属。
     // 单字段独立解析(occurrence / responsible 各自走优先级)。任一字段最终为 null → 调用方按"throw 让 scheduler 跳过"处理。
     // 不允许写 0 作为最终值;不硬编码 1=质量部 / 2=生产部;不猜测业务对象部门派生(待后续增强)。
@@ -445,7 +465,10 @@ public class S8ManualReportService : ITransient
                 dto.SceneCode, inferredType, dto.Title);
         }
 
-        var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now,避免漂移。
+        var now = DateTime.Now;
+        var slaDeadline = await ResolveSlaDeadlineAsync(inferredType, now);
+        var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var entity = new AdoS8Exception
         {
             TenantId = dto.TenantId,
@@ -468,7 +491,9 @@ public class S8ManualReportService : ITransient
             // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
             // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
             ProcessNodeCode = null,
-            CreatedAt = DateTime.Now,
+            CreatedAt = now,
+            // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定;缺配置 → null。
+            SlaDeadline = slaDeadline,
             IsDeleted = false
         };
 
@@ -514,7 +539,9 @@ public class S8ManualReportService : ITransient
         if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
             throw new S8BizException("自动建单缺失追溯键");
 
-        var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。
+        var now = DateTime.Now;
+        var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
         var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})";
         // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:固定 SceneCode=S2 + ExceptionTypeCode=EQUIP_FAULT,
         // 派生链路通过 ResolveModuleCodeAsync 严格按 S1-S7 走(结果稳定为 S2,但消除 FromScene 的 legacy 兼容路径)。
@@ -574,7 +601,9 @@ public class S8ManualReportService : ITransient
             OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
             ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
             ReporterId = null,
-            CreatedAt = DateTime.Now,
+            CreatedAt = now,
+            // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定(EQUIP_FAULT)。
+            SlaDeadline = await ResolveSlaDeadlineAsync("EQUIP_FAULT", now),
             IsDeleted = false,
             // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
             ExceptionTypeCode = "EQUIP_FAULT",
@@ -669,7 +698,10 @@ public class S8ManualReportService : ITransient
             throw new S8BizException($"自动建单部门解析失败:缺少未归属部门基线(factory_ref_id={fixedFactoryId}, codename={UnassignedDepartmentCode})");
         }
 
-        var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):CreatedAt 与 SlaDeadline 共用同一 now。
+        var now = DateTime.Now;
+        var code = $"EX-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
+        var slaDeadline = await ResolveSlaDeadlineAsync(hit.ExceptionTypeCode, now);
         var entity = new AdoS8Exception
         {
             // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。
@@ -690,7 +722,9 @@ public class S8ManualReportService : ITransient
             OccurrenceDeptId = deptResolution.OccurrenceDeptId.Value,
             ResponsibleDeptId = deptResolution.ResponsibleDeptId.Value,
             ReporterId = null,
-            CreatedAt = DateTime.Now,
+            CreatedAt = now,
+            // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 来自 hit.ExceptionTypeCode 对应 sla_minutes。
+            SlaDeadline = slaDeadline,
             IsDeleted = false,
             ExceptionTypeCode = hit.ExceptionTypeCode,
             ModuleCode = resolvedModule,

+ 5 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -220,16 +220,18 @@ public class S8MonitoringService : ITransient
             .WhereIF(q.BizDateTo.HasValue, e => e.CreatedAt <= q.BizDateTo!.Value);
 
         // 聚合到内存(数据量在可控范围内,避免复杂 GROUP BY 兼容性问题)
+        // S8-SLA-TIMEOUT-RUNTIME-1(P3):投影 SlaDeadline 替代 TimeoutFlag,timeout 改为运行时计算。
         var raw = await query
             .Select(e => new
             {
                 e.ModuleCode,
                 e.SceneCode,
                 e.Severity,
-                e.TimeoutFlag,
+                e.SlaDeadline,
                 e.Status
             })
             .ToListAsync();
+        var nowForTimeout = DateTime.Now;
 
         var byModule = raw
             .GroupBy(e => new { mc = e.ModuleCode ?? string.Empty, sc = e.SceneCode ?? string.Empty })
@@ -244,7 +246,7 @@ public class S8MonitoringService : ITransient
                 Red     = g.Count(e => S8SeverityCode.IsSerious(e.Severity)),
                 Yellow  = g.Count(e => S8SeverityCode.IsFollow(e.Severity)),
                 Green   = 0,
-                Timeout = g.Count(e => e.TimeoutFlag && e.Status != "CLOSED")
+                Timeout = g.Count(e => e.SlaDeadline != null && e.SlaDeadline < nowForTimeout && e.Status != "CLOSED" && e.Status != "RECOVERED")
             })
             // 按 S8ModuleCode.All 顺序排列
             .OrderBy(r => Array.IndexOf(S8ModuleCode.All, r.ModuleCode))
@@ -257,7 +259,7 @@ public class S8MonitoringService : ITransient
             Red     = raw.Count(e => S8SeverityCode.IsSerious(e.Severity)),
             Yellow  = raw.Count(e => S8SeverityCode.IsFollow(e.Severity)),
             Green   = 0,
-            Timeout = raw.Count(e => e.TimeoutFlag && e.Status != "CLOSED"),
+            Timeout = raw.Count(e => e.SlaDeadline != null && e.SlaDeadline < nowForTimeout && e.Status != "CLOSED" && e.Status != "RECOVERED"),
             ByModule = byModule
         };
     }