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; }
}