using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// /// S8-DEMO-IMPACT-SORT-NOTICE-1:异常影响统计服务。 /// 滚动 30 天窗口;归类键 = (tenant_id, factory_id, exception_type_code); /// 累计损失只统计已关闭异常(closed_at - created_at);未关闭不计入; /// 算法/阈值见 Compute 方法内常量;运行期计算,不落库。 /// public class S8ImpactMetricsService : ITransient { private readonly SqlSugarRepository _rep; // 影响分权重上限(保护 score 不超过设计区间 0-140) private const int RepeatCap = 5; private const decimal LossHoursCap = 72m; private const decimal LossWeightCap = 30m; private const decimal SeverityWeightSerious = 40m; private const decimal SeverityWeightFollow = 10m; private const decimal TimeoutWeight = 20m; private const decimal RepeatUnitWeight = 10m; // 关注级别阈值 private const int HighRepeatThreshold = 3; private const decimal HighLossThreshold = 24m; private const int MediumRepeatThreshold = 2; private const decimal MediumLossThreshold = 8m; public S8ImpactMetricsService(SqlSugarRepository rep) { _rep = rep; } /// /// 批量计算:传入候选异常子集(同一 tenant/factory),一次性按 exception_type_code 聚合 30 天窗口数据, /// 内存填充各行 6 个影响字段。无 N+1。candidates 为空或 typeCode 全空时不查库。 /// public async Task FillBatchAsync( long tenantId, long factoryId, IReadOnlyList candidates) { if (candidates == null || candidates.Count == 0) return; var since = DateTime.Now.AddDays(-30); var typeCodes = candidates .Select(c => c.ExceptionTypeCode) .Where(c => !string.IsNullOrWhiteSpace(c)) .Distinct() .ToList(); var repeatMap = new Dictionary(StringComparer.Ordinal); var lossMap = new Dictionary(StringComparer.Ordinal); if (typeCodes.Count > 0) { // repeatCount30d:按 exception_type_code GROUP BY 计数全部行(含未关闭)。 var repeatRows = await _rep.Context.Queryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.ExceptionTypeCode != null && typeCodes.Contains(x.ExceptionTypeCode) && x.CreatedAt >= since) .GroupBy(x => x.ExceptionTypeCode!) .Select(x => new { ExceptionTypeCode = x.ExceptionTypeCode!, RepeatCount = SqlFunc.AggregateCount(x.Id), }) .ToListAsync(); foreach (var r in repeatRows) { repeatMap[r.ExceptionTypeCode] = r.RepeatCount; } // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at) 分钟差累计。 var lossRows = await _rep.Context.Queryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.ExceptionTypeCode != null && typeCodes.Contains(x.ExceptionTypeCode) && x.CreatedAt >= since && x.ClosedAt != null) .GroupBy(x => x.ExceptionTypeCode!) .Select(x => new { ExceptionTypeCode = x.ExceptionTypeCode!, LossMinutes = SqlFunc.AggregateSum( SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value)), }) .ToListAsync(); foreach (var r in lossRows) { lossMap[r.ExceptionTypeCode] = Math.Round((decimal)r.LossMinutes / 60m, 1); } } foreach (var c in candidates) { int repeatCount; decimal lossHours; if (string.IsNullOrWhiteSpace(c.ExceptionTypeCode)) { // exception_type_code 为空 → 视为孤例:自身计 1 次;若自身已关闭则累计自身损失 repeatCount = 1; lossHours = (c.ClosedAt != null) ? Math.Round((decimal)(c.ClosedAt.Value - c.CreatedAt).TotalHours, 1) : 0m; } else { repeatCount = repeatMap.TryGetValue(c.ExceptionTypeCode, out var rc) ? rc : 1; lossHours = lossMap.TryGetValue(c.ExceptionTypeCode, out var lh) ? lh : 0m; } var (impactScore, level, label, reason) = Compute(c.Severity, repeatCount, lossHours, c.TimeoutFlag); c.RepeatCount30d = repeatCount; c.CumulativeLossHours30d = lossHours; c.ImpactScore = impactScore; c.SuggestedAttentionLevel = level; c.SuggestedAttentionLabel = label; c.ImpactReason = reason; } } /// /// 单异常计算:通知派发路径调用;exception_type_code / created_at / closed_at / severity / sla_deadline 从实体读取。 /// 同样的 30 天窗口聚合,但只查询当前 typeCode 一条。 /// public async Task ComputeOneAsync(AdoS8Exception entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); var since = DateTime.Now.AddDays(-30); var now = DateTime.Now; var timeoutFlag = entity.SlaDeadline != null && entity.SlaDeadline < now && entity.Status != "CLOSED" && entity.Status != "RECOVERED"; int repeatCount; decimal lossHours; if (string.IsNullOrWhiteSpace(entity.ExceptionTypeCode)) { repeatCount = 1; lossHours = (entity.ClosedAt != null) ? Math.Round((decimal)(entity.ClosedAt.Value - entity.CreatedAt).TotalHours, 1) : 0m; } else { var typeCode = entity.ExceptionTypeCode; // repeatCount30d:30 天窗口内同 typeCode 全量计数(含未关闭)。 repeatCount = await _rep.Context.Queryable() .Where(x => x.TenantId == entity.TenantId && x.FactoryId == entity.FactoryId && !x.IsDeleted && x.ExceptionTypeCode == typeCode && x.CreatedAt >= since) .CountAsync(); if (repeatCount == 0) repeatCount = 1; // 自身刚建尚未可见时回退 1。 // cumulativeLossHours30d:仅已关闭异常 (closed_at - created_at)。 var lossMinutesNullable = await _rep.Context.Queryable() .Where(x => x.TenantId == entity.TenantId && x.FactoryId == entity.FactoryId && !x.IsDeleted && x.ExceptionTypeCode == typeCode && x.CreatedAt >= since && x.ClosedAt != null) .SumAsync(x => (int?)SqlFunc.DateDiff(DateType.Minute, x.CreatedAt, x.ClosedAt!.Value)); var lossMinutes = lossMinutesNullable ?? 0; lossHours = Math.Round((decimal)lossMinutes / 60m, 1); } var (impactScore, level, label, reason) = Compute(entity.Severity, repeatCount, lossHours, timeoutFlag); return new ExceptionImpactSnapshot { RepeatCount30d = repeatCount, CumulativeLossHours30d = lossHours, ImpactScore = impactScore, SuggestedAttentionLevel = level, SuggestedAttentionLabel = label, ImpactReason = reason, }; } /// /// 算法实现。Severity 已通过 S8SeverityCode.Normalize 统一为 FOLLOW / SERIOUS。 /// score = severityWeight + min(repeat,5)*10 + min(loss,72)/72*30 + (timeout?20:0),理论范围 0-140。 /// 关注级别按重复 / 损失 / 严重度 / 超时四因子判定。 /// private static (decimal score, string level, string label, string reason) Compute( string? severity, int repeatCount, decimal lossHours, bool timeoutFlag) { var normalizedSeverity = S8SeverityCode.Normalize(severity); var severityWeight = normalizedSeverity switch { "SERIOUS" => SeverityWeightSerious, "FOLLOW" => SeverityWeightFollow, _ => 0m, }; var cappedRepeat = Math.Min(repeatCount, RepeatCap); var repeatWeight = cappedRepeat * RepeatUnitWeight; var cappedLoss = Math.Min(lossHours, LossHoursCap); var lossWeight = cappedLoss / LossHoursCap * LossWeightCap; var timeoutWeight = timeoutFlag ? TimeoutWeight : 0m; var impactScore = Math.Round(severityWeight + repeatWeight + lossWeight + timeoutWeight, 1); string level; if (normalizedSeverity == "SERIOUS" || repeatCount >= HighRepeatThreshold || lossHours >= HighLossThreshold) { level = "HIGH"; } else if (repeatCount >= MediumRepeatThreshold || lossHours >= MediumLossThreshold || timeoutFlag) { level = "MEDIUM"; } else { level = "LOW"; } var label = level switch { "HIGH" => "高", "MEDIUM" => "中", "LOW" => "低", _ => "—", }; var reasonParts = new List(4); if (repeatCount > 1) reasonParts.Add($"重复 {repeatCount} 次"); if (lossHours > 0) reasonParts.Add($"累计损失 {lossHours.ToString("0.#")} 小时"); if (normalizedSeverity == "SERIOUS") reasonParts.Add("严重"); if (timeoutFlag) reasonParts.Add("当前超时"); var reason = reasonParts.Count == 0 ? "无显著影响" : string.Join("/", reasonParts); return (impactScore, level, label, reason); } } /// /// S8-DEMO-IMPACT-SORT-NOTICE-1:批量影响计算的最小行契约。 /// S8ExceptionService 投影时构造,FillBatchAsync 在内存回填 6 个 30d 字段后,再赋给 DTO。 /// public sealed class ExceptionImpactRow { public long Id { get; set; } public string? ExceptionTypeCode { get; set; } public string? Severity { get; set; } public DateTime CreatedAt { get; set; } public DateTime? ClosedAt { get; set; } public bool TimeoutFlag { get; set; } public int RepeatCount30d { get; set; } public decimal CumulativeLossHours30d { get; set; } public decimal ImpactScore { get; set; } public string? SuggestedAttentionLevel { get; set; } public string? SuggestedAttentionLabel { get; set; } public string? ImpactReason { get; set; } } /// /// 单异常计算结果快照;通知派发路径使用。 /// public sealed class ExceptionImpactSnapshot { public int RepeatCount30d { get; set; } public decimal CumulativeLossHours30d { get; set; } public decimal ImpactScore { get; set; } public string? SuggestedAttentionLevel { get; set; } public string? SuggestedAttentionLabel { get; set; } public string? ImpactReason { get; set; } }