|
|
@@ -13,17 +13,24 @@ public class S8ExceptionService : ITransient
|
|
|
private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
|
|
|
private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
|
|
|
private readonly SqlSugarRepository<SysUser> _sysUserRep;
|
|
|
+ private readonly S8ImpactMetricsService _impactMetricsService;
|
|
|
+
|
|
|
+ // S8-DEMO-IMPACT-SORT-NOTICE-1:影响排序的候选集合上限。
|
|
|
+ // 超过此阈值时降级为 CreatedAt DESC 走 DB 分页,避免全量取 → 内存排序在生产规模下退化。
|
|
|
+ private const int ImpactSortCandidateCap = 2000;
|
|
|
|
|
|
public S8ExceptionService(
|
|
|
SqlSugarRepository<AdoS8Exception> rep,
|
|
|
SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
|
|
|
SqlSugarRepository<AdoS0EmployeeMaster> empRep,
|
|
|
- SqlSugarRepository<SysUser> sysUserRep)
|
|
|
+ SqlSugarRepository<SysUser> sysUserRep,
|
|
|
+ S8ImpactMetricsService impactMetricsService)
|
|
|
{
|
|
|
_rep = rep;
|
|
|
_deptRep = deptRep;
|
|
|
_empRep = empRep;
|
|
|
_sysUserRep = sysUserRep;
|
|
|
+ _impactMetricsService = impactMetricsService;
|
|
|
}
|
|
|
|
|
|
public async Task<(int total, List<AdoS8ExceptionListItemDto> list)> GetPagedAsync(AdoS8ExceptionQueryDto q)
|
|
|
@@ -73,55 +80,191 @@ public class S8ExceptionService : ITransient
|
|
|
(e, sc, wr) => e.Title.Contains(q.Keyword!) || e.ExceptionCode.Contains(q.Keyword!));
|
|
|
|
|
|
var total = await query.CountAsync();
|
|
|
- var list = await query
|
|
|
- .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
|
|
|
- .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
|
|
|
+
|
|
|
+ // S8-DEMO-IMPACT-SORT-NOTICE-1:候选集合 <= ImpactSortCandidateCap 时走"全量取 → 内存填影响 → 排序 → 分页",
|
|
|
+ // 支持 impactScore / repeatCount30d / cumulativeLossHours30d 等运行期字段排序;超过则降级 DB 分页 createdAt DESC。
|
|
|
+ var useImpactSort = total > 0 && total <= ImpactSortCandidateCap;
|
|
|
+
|
|
|
+ List<AdoS8ExceptionListItemDto> list;
|
|
|
+ if (useImpactSort)
|
|
|
+ {
|
|
|
+ // 先按 query 命中集合一次性取候选,order 仅 createdAt DESC 用于稳定性,后续内存覆写排序。
|
|
|
+ var candidates = await query
|
|
|
+ .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
|
|
|
+ .Take(ImpactSortCandidateCap)
|
|
|
+ .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
|
|
|
+ {
|
|
|
+ Id = e.Id,
|
|
|
+ FactoryId = e.FactoryId,
|
|
|
+ ExceptionCode = e.ExceptionCode,
|
|
|
+ Title = e.Title,
|
|
|
+ Status = e.Status,
|
|
|
+ Severity = e.Severity,
|
|
|
+ PriorityScore = e.PriorityScore,
|
|
|
+ PriorityLevel = e.PriorityLevel,
|
|
|
+ SceneCode = e.SceneCode,
|
|
|
+ SceneName = sc.SceneName,
|
|
|
+ ModuleCode = e.ModuleCode,
|
|
|
+ ResponsibleDeptId = e.ResponsibleDeptId,
|
|
|
+ OccurrenceDeptId = e.OccurrenceDeptId,
|
|
|
+ AssigneeId = e.AssigneeId,
|
|
|
+ SlaDeadline = e.SlaDeadline,
|
|
|
+ TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
|
|
|
+ CreatedAt = e.CreatedAt,
|
|
|
+ ClosedAt = e.ClosedAt,
|
|
|
+ ExceptionTypeCode = e.ExceptionTypeCode,
|
|
|
+ RecoveredAt = e.RecoveredAt,
|
|
|
+ SourceRuleCode = e.SourceRuleCode,
|
|
|
+ SourceObjectType = e.SourceObjectType,
|
|
|
+ SourceObjectId = e.SourceObjectId,
|
|
|
+ DedupKey = e.DedupKey,
|
|
|
+ LastDetectedAt = e.LastDetectedAt,
|
|
|
+ RuleType = wr.RuleType,
|
|
|
+ RelatedObjectCode = e.RelatedObjectCode,
|
|
|
+ OrderFlowCode = e.OrderFlowCode,
|
|
|
+ StageCode = e.StageCode,
|
|
|
+ RuleMechanism = e.RuleMechanism,
|
|
|
+ })
|
|
|
+ .ToListAsync();
|
|
|
+
|
|
|
+ // DTO 后处理:损失时间 / 是否超时关闭 / 标签。
|
|
|
+ foreach (var r in candidates)
|
|
|
{
|
|
|
- Id = e.Id,
|
|
|
- FactoryId = e.FactoryId,
|
|
|
- ExceptionCode = e.ExceptionCode,
|
|
|
- Title = e.Title,
|
|
|
- Status = e.Status,
|
|
|
- Severity = e.Severity,
|
|
|
- PriorityScore = e.PriorityScore,
|
|
|
- PriorityLevel = e.PriorityLevel,
|
|
|
- SceneCode = e.SceneCode,
|
|
|
- SceneName = sc.SceneName,
|
|
|
- ModuleCode = e.ModuleCode,
|
|
|
- ResponsibleDeptId = e.ResponsibleDeptId,
|
|
|
- OccurrenceDeptId = e.OccurrenceDeptId,
|
|
|
- AssigneeId = e.AssigneeId,
|
|
|
- SlaDeadline = e.SlaDeadline,
|
|
|
- // 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,
|
|
|
- RecoveredAt = e.RecoveredAt,
|
|
|
- SourceRuleCode = e.SourceRuleCode,
|
|
|
- SourceObjectType = e.SourceObjectType,
|
|
|
- SourceObjectId = e.SourceObjectId,
|
|
|
- DedupKey = e.DedupKey,
|
|
|
- LastDetectedAt = e.LastDetectedAt,
|
|
|
- RuleType = wr.RuleType,
|
|
|
- RelatedObjectCode = e.RelatedObjectCode,
|
|
|
- OrderFlowCode = e.OrderFlowCode,
|
|
|
- StageCode = e.StageCode,
|
|
|
- RuleMechanism = e.RuleMechanism,
|
|
|
- })
|
|
|
- .ToPageListAsync(q.Page, q.PageSize);
|
|
|
+ r.StatusLabel = S8Labels.StatusLabel(r.Status);
|
|
|
+ r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
|
|
|
+ r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
|
|
|
+ var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline);
|
|
|
+ r.LossHours = lossHours;
|
|
|
+ r.IsOverdueClosed = isOverdueClosed;
|
|
|
+ }
|
|
|
|
|
|
- foreach (var r in list)
|
|
|
+ // 影响统计批量填充(30 天窗口聚合一次性 GROUP BY,O(n) 回填,无 N+1)。
|
|
|
+ var impactRows = candidates
|
|
|
+ .Select(r => new ExceptionImpactRow
|
|
|
+ {
|
|
|
+ Id = r.Id,
|
|
|
+ ExceptionTypeCode = r.ExceptionTypeCode,
|
|
|
+ Severity = r.Severity,
|
|
|
+ CreatedAt = r.CreatedAt,
|
|
|
+ ClosedAt = r.ClosedAt,
|
|
|
+ TimeoutFlag = r.TimeoutFlag,
|
|
|
+ })
|
|
|
+ .ToList();
|
|
|
+ await _impactMetricsService.FillBatchAsync(q.TenantId, q.FactoryId, impactRows);
|
|
|
+ var impactById = impactRows.ToDictionary(x => x.Id);
|
|
|
+ foreach (var r in candidates)
|
|
|
+ {
|
|
|
+ if (!impactById.TryGetValue(r.Id, out var snap)) continue;
|
|
|
+ r.RepeatCount30d = snap.RepeatCount30d;
|
|
|
+ r.CumulativeLossHours30d = snap.CumulativeLossHours30d;
|
|
|
+ r.ImpactScore = snap.ImpactScore;
|
|
|
+ r.SuggestedAttentionLevel = snap.SuggestedAttentionLevel;
|
|
|
+ r.SuggestedAttentionLabel = snap.SuggestedAttentionLabel;
|
|
|
+ r.ImpactReason = snap.ImpactReason;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 内存排序(白名单,非白名单回退 createdAt DESC)。
|
|
|
+ var sorted = ApplyImpactSort(candidates, q.SortField, q.SortOrder);
|
|
|
+
|
|
|
+ // 内存分页。
|
|
|
+ list = sorted
|
|
|
+ .Skip((q.Page - 1) * q.PageSize)
|
|
|
+ .Take(q.PageSize)
|
|
|
+ .ToList();
|
|
|
+ }
|
|
|
+ else
|
|
|
{
|
|
|
- r.StatusLabel = S8Labels.StatusLabel(r.Status);
|
|
|
- r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
|
|
|
- r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
|
|
|
+ // 降级路径:候选 > ImpactSortCandidateCap,DB 分页 createdAt DESC,不填充影响字段(前端展示 0/null)。
|
|
|
+ list = await query
|
|
|
+ .OrderBy((e, sc, wr) => e.CreatedAt, OrderByType.Desc)
|
|
|
+ .Select((e, sc, wr) => new AdoS8ExceptionListItemDto
|
|
|
+ {
|
|
|
+ Id = e.Id,
|
|
|
+ FactoryId = e.FactoryId,
|
|
|
+ ExceptionCode = e.ExceptionCode,
|
|
|
+ Title = e.Title,
|
|
|
+ Status = e.Status,
|
|
|
+ Severity = e.Severity,
|
|
|
+ PriorityScore = e.PriorityScore,
|
|
|
+ PriorityLevel = e.PriorityLevel,
|
|
|
+ SceneCode = e.SceneCode,
|
|
|
+ SceneName = sc.SceneName,
|
|
|
+ ModuleCode = e.ModuleCode,
|
|
|
+ ResponsibleDeptId = e.ResponsibleDeptId,
|
|
|
+ OccurrenceDeptId = e.OccurrenceDeptId,
|
|
|
+ AssigneeId = e.AssigneeId,
|
|
|
+ SlaDeadline = e.SlaDeadline,
|
|
|
+ TimeoutFlag = e.SlaDeadline != null && e.SlaDeadline < timeoutNow && e.Status != "CLOSED" && e.Status != "RECOVERED",
|
|
|
+ CreatedAt = e.CreatedAt,
|
|
|
+ ClosedAt = e.ClosedAt,
|
|
|
+ ExceptionTypeCode = e.ExceptionTypeCode,
|
|
|
+ RecoveredAt = e.RecoveredAt,
|
|
|
+ SourceRuleCode = e.SourceRuleCode,
|
|
|
+ SourceObjectType = e.SourceObjectType,
|
|
|
+ SourceObjectId = e.SourceObjectId,
|
|
|
+ DedupKey = e.DedupKey,
|
|
|
+ LastDetectedAt = e.LastDetectedAt,
|
|
|
+ RuleType = wr.RuleType,
|
|
|
+ RelatedObjectCode = e.RelatedObjectCode,
|
|
|
+ OrderFlowCode = e.OrderFlowCode,
|
|
|
+ StageCode = e.StageCode,
|
|
|
+ RuleMechanism = e.RuleMechanism,
|
|
|
+ })
|
|
|
+ .ToPageListAsync(q.Page, q.PageSize);
|
|
|
+
|
|
|
+ foreach (var r in list)
|
|
|
+ {
|
|
|
+ r.StatusLabel = S8Labels.StatusLabel(r.Status);
|
|
|
+ r.SeverityLabel = S8Labels.SeverityLabel(r.Severity);
|
|
|
+ r.ModuleName = string.IsNullOrWhiteSpace(r.ModuleCode) ? null : S8ModuleCode.Label(r.ModuleCode!);
|
|
|
+ var (lossHours, isOverdueClosed) = ComputeClosureMetrics(r.CreatedAt, r.ClosedAt, r.SlaDeadline);
|
|
|
+ r.LossHours = lossHours;
|
|
|
+ r.IsOverdueClosed = isOverdueClosed;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
await FillDisplayNamesAsync(list, q.FactoryId);
|
|
|
return (total, list);
|
|
|
}
|
|
|
|
|
|
+ // S8-DEMO-IMPACT-SORT-NOTICE-1:白名单排序;severity 自定义键(SERIOUS=2 / FOLLOW=1 / 其他=0)。
|
|
|
+ // sortField 空或非白名单 → createdAt DESC;sortOrder 非 asc/desc → desc。
|
|
|
+ private static List<AdoS8ExceptionListItemDto> ApplyImpactSort(
|
|
|
+ List<AdoS8ExceptionListItemDto> rows, string? sortField, string? sortOrder)
|
|
|
+ {
|
|
|
+ var asc = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase);
|
|
|
+ var key = (sortField ?? string.Empty).Trim();
|
|
|
+ return key switch
|
|
|
+ {
|
|
|
+ "impactScore" => asc
|
|
|
+ ? rows.OrderBy(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList()
|
|
|
+ : rows.OrderByDescending(r => r.ImpactScore).ThenByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ "repeatCount30d" => asc
|
|
|
+ ? rows.OrderBy(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList()
|
|
|
+ : rows.OrderByDescending(r => r.RepeatCount30d).ThenByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ "cumulativeLossHours30d" => asc
|
|
|
+ ? rows.OrderBy(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList()
|
|
|
+ : rows.OrderByDescending(r => r.CumulativeLossHours30d).ThenByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ "severity" => asc
|
|
|
+ ? rows.OrderBy(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList()
|
|
|
+ : rows.OrderByDescending(r => SeveritySortKey(r.Severity)).ThenByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ "priorityScore" => asc
|
|
|
+ ? rows.OrderBy(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList()
|
|
|
+ : rows.OrderByDescending(r => r.PriorityScore).ThenByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ "createdAt" => asc
|
|
|
+ ? rows.OrderBy(r => r.CreatedAt).ToList()
|
|
|
+ : rows.OrderByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ _ => rows.OrderByDescending(r => r.CreatedAt).ToList(),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int SeveritySortKey(string severity) => severity switch
|
|
|
+ {
|
|
|
+ "SERIOUS" => 2,
|
|
|
+ "FOLLOW" => 1,
|
|
|
+ _ => 0,
|
|
|
+ };
|
|
|
+
|
|
|
public async Task<object> GetFilterOptionsAsync(long tenantId, long factoryId)
|
|
|
{
|
|
|
var scenes = await _rep.Context.Queryable<AdoS8SceneConfig>()
|
|
|
@@ -218,10 +361,28 @@ public class S8ExceptionService : ITransient
|
|
|
d.StatusLabel = S8Labels.StatusLabel(d.Status);
|
|
|
d.SeverityLabel = S8Labels.SeverityLabel(d.Severity);
|
|
|
d.ModuleName = string.IsNullOrWhiteSpace(d.ModuleCode) ? null : S8ModuleCode.Label(d.ModuleCode!);
|
|
|
+ // S8-DEMO-CORE-FIELD-COMPLETE-1:详情与列表同口径,DTO 后处理计算损失时间 / 是否超时关闭。
|
|
|
+ var (lossHours, isOverdueClosed) = ComputeClosureMetrics(d.CreatedAt, d.ClosedAt, d.SlaDeadline);
|
|
|
+ d.LossHours = lossHours;
|
|
|
+ d.IsOverdueClosed = isOverdueClosed;
|
|
|
await FillDisplayNamesAsync(new[] { d }, factoryId);
|
|
|
return d;
|
|
|
}
|
|
|
|
|
|
+ // S8-DEMO-CORE-FIELD-COMPLETE-1:损失时间(小时,1 位小数)+ 是否超时关闭(冻结判定)。
|
|
|
+ // 未关闭 → LossHours=null;未关闭或无 SLA → IsOverdueClosed=null。与运行时 TimeoutFlag 不复用。
|
|
|
+ private static (decimal? lossHours, bool? isOverdueClosed) ComputeClosureMetrics(
|
|
|
+ DateTime createdAt, DateTime? closedAt, DateTime? slaDeadline)
|
|
|
+ {
|
|
|
+ decimal? lossHours = closedAt == null
|
|
|
+ ? (decimal?)null
|
|
|
+ : Math.Round((decimal)(closedAt.Value - createdAt).TotalHours, 1);
|
|
|
+ bool? isOverdueClosed = (closedAt == null || slaDeadline == null)
|
|
|
+ ? (bool?)null
|
|
|
+ : closedAt.Value > slaDeadline.Value;
|
|
|
+ return (lossHours, isOverdueClosed);
|
|
|
+ }
|
|
|
+
|
|
|
// S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-2/A-3):list 行也水合 OccurrenceDeptName;
|
|
|
// 部门查询加 factory_ref_id 二次约束,避免跨 factory 同名 / 同 RecID 错位。
|
|
|
private async Task FillDisplayNamesAsync(IEnumerable<AdoS8ExceptionListItemDto> rows, long factoryId)
|