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; namespace Admin.NET.Plugin.AiDOP.Service.S8; public class S8DashboardService : ITransient { // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:byObject / dim-trends(object) 维度统一 NULL 归桶口径。 // related_object_code 为 NULL/空白 → 归入"未关联",避免主图空数据误导。 private const string UnlinkedObjectLabel = "未关联"; private static string NormalizeRelatedObjectCode(string? relatedObjectCode) => string.IsNullOrWhiteSpace(relatedObjectCode) ? UnlinkedObjectLabel : relatedObjectCode!.Trim(); private readonly SqlSugarRepository _rep; private readonly SqlSugarRepository _deptRep; private readonly SqlSugarRepository _processNodeRep; public S8DashboardService( SqlSugarRepository rep, SqlSugarRepository deptRep, SqlSugarRepository processNodeRep) { _rep = rep; _deptRep = deptRep; _processNodeRep = processNodeRep; } public async Task GetOverviewAsync(long tenantId, long factoryId, DateTime? beginTime = null, DateTime? endTime = null, string? severity = null, string? period = null) { // 看板顶部的"开始/结束日期"通过 beginTime/endTime 透传,与列表的 beginTime/endTime 同语义。 // BUG-12:补 severity 过滤,与异常列表 API 的 severity 口径一致(精确匹配 LOW/MEDIUM/HIGH/CRITICAL)。 // BUG-22:REJECTED 状态归入 pending 桶,避免从总数视角"消失";与列表 statusBucket="pending" 同步对齐。 // S8-DASHBOARD-DATA-ALIGN-S1S7-1:统一只统计 module_code IN S1-S7;NULL module 的 legacy 行不参与看板 KPI。 // 显式日期范围优先;未传时从 period 解析。 var (periodFrom, periodTo) = (!beginTime.HasValue && !endTime.HasValue) ? S8PeriodHelper.Resolve(period) : ((DateTime?)null, (DateTime?)null); var effectiveFrom = beginTime ?? periodFrom; var effectiveTo = endTime ?? periodTo; var q = _rep.AsQueryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted) .Where(x => S8ModuleCode.All.Contains(x.ModuleCode)) .WhereIF(effectiveFrom.HasValue, x => x.CreatedAt >= effectiveFrom!.Value) .WhereIF(effectiveTo.HasValue, x => x.CreatedAt < effectiveTo!.Value) .WhereIF(!string.IsNullOrWhiteSpace(severity), x => x.Severity == severity); 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-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)。 var seriousList = await q.Select(x => x.Severity).ToListAsync(); var critical = seriousList.Count(s => S8SeverityCode.IsSerious(s)); var closureRate = total > 0 ? Math.Round(closed * 100.0 / total, 1) : 0.0; // 平均处理周期(小时),仅对已闭环且有关闭时间的记录计算 var closedRows = await q .Where(x => x.Status == "CLOSED" && x.ClosedAt != null) .Select(x => new { x.CreatedAt, x.ClosedAt }) .ToListAsync(); var avgCycleHours = closedRows.Count > 0 ? Math.Round(closedRows.Average(x => (x.ClosedAt!.Value - x.CreatedAt).TotalHours), 1) : 0.0; return new { total, pending, inProgress, timeout, closed, todayNew, critical, closureRate, avgCycleHours }; } public async Task GetTrendsAsync(long tenantId, long factoryId, int days, string? period = null) { var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period); DateTime from, toExclusive; if (periodFrom.HasValue && periodTo.HasValue) { from = periodFrom.Value; toExclusive = periodTo.Value; } else { from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90)); toExclusive = DateTime.Today.AddDays(1); } var rows = await _rep.AsQueryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from && x.CreatedAt < toExclusive) .Where(x => S8ModuleCode.All.Contains(x.ModuleCode)) .Select(x => new { x.CreatedAt }) .ToListAsync(); return rows .GroupBy(x => x.CreatedAt.Date) .OrderBy(g => g.Key) .Select(g => new { date = g.Key.ToString("yyyy-MM-dd"), count = g.Count() }) .ToList(); } public async Task GetDistributionsAsync(long tenantId, long factoryId, string? period = null) { var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period); var list = await _rep.AsQueryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted) .Where(x => S8ModuleCode.All.Contains(x.ModuleCode)) .WhereIF(periodFrom.HasValue, x => x.CreatedAt >= periodFrom!.Value) .WhereIF(periodTo.HasValue, x => x.CreatedAt < periodTo!.Value) .Select(x => new { x.Status, x.SceneCode, x.Severity, x.ResponsibleDeptId, x.OccurrenceDeptId, // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:byProcess 改用 module_code 聚合。 x.ModuleCode, x.RelatedObjectCode, }) .ToListAsync(); // 部门名称字典 var allDeptIds = list.Select(x => x.ResponsibleDeptId) .Concat(list.Select(x => x.OccurrenceDeptId)) .Distinct().ToList(); // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):部门水合加 factory_ref_id 约束,避免跨 factory 同 RecID 错位。 var deptMap = await _deptRep.AsQueryable() .Where(d => allDeptIds.Contains(d.Id) && d.FactoryRefId == factoryId) .Select(d => new { d.Id, Name = d.Descr ?? d.Department }) .ToListAsync(); var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString()); // 流程节点名称字典 var processNodes = await _processNodeRep.AsQueryable() .OrderBy(p => p.SortNo) .Select(p => new { p.Code, p.Name }) .ToListAsync(); var processDict = processNodes.ToDictionary(p => p.Code, p => p.Name); return new { byStatus = list .GroupBy(x => x.Status) .Select(g => new { key = g.Key, count = g.Count() }), // S8-DASHBOARD-BYSCENE-RESERVE-CLEAR-1:byScene 不再生成。 // 业务展示主口径已切到 module_code(byProcess),保留 scene_code 仅用于建单/规则唯一性/配置 CRUD/历史追溯。 // 字段保留以维持响应结构兼容;前端无消费方。 byScene = Array.Empty(), // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:bySeverity 归一为 FOLLOW/SERIOUS 两桶。 bySeverity = list .GroupBy(x => S8SeverityCode.Normalize(x.Severity)) .Select(g => new { key = g.Key, count = g.Count() }), byDept = list .GroupBy(x => x.ResponsibleDeptId) .Select(g => new { key = g.Key, deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()), count = g.Count(), }), byOccurrenceDept = list .GroupBy(x => x.OccurrenceDeptId) .Select(g => new { key = g.Key, deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()), count = g.Count(), }), // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段「流程环节」按 module_code 聚合 // (process_node_code 已挂起,留给未来更细流程节点)。 // module_code 已经过 S1-S7 过滤(见 line 87 Where(S8ModuleCode.All.Contains(...))),无需再 NULL 兜底。 // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:byProcess 增加 followCount/seriousCount 两段, // 供前端「流程环节」黄红堆叠柱使用;count = follow + serious。排序按 S1-S7 自然字典序固定。 byProcess = list .GroupBy(x => x.ModuleCode) .Select(g => new { key = g.Key, nodeName = processDict.GetValueOrDefault(g.Key, g.Key), count = g.Count(), followCount = g.Count(x => S8SeverityCode.IsFollow(x.Severity)), seriousCount = g.Count(x => S8SeverityCode.IsSerious(x.Severity)), }) .OrderBy(r => r.key), byObject = list // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:不再过滤 NULL,与 dim-trends 口径统一; // related_object_code 为空时归入"未关联"桶。 .GroupBy(x => NormalizeRelatedObjectCode(x.RelatedObjectCode)) .OrderByDescending(g => g.Count()) .Take(20) .Select(g => new { key = g.Key, count = g.Count() }), }; } public async Task GetDeptBacklogAsync(long tenantId, long factoryId, string? period = null) { var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" }; var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period); // 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)) .WhereIF(periodFrom.HasValue, x => x.CreatedAt >= periodFrom!.Value) .WhereIF(periodTo.HasValue, x => x.CreatedAt < periodTo!.Value) .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 约束。 var deptMap = await _deptRep.AsQueryable() .Where(d => deptIds.Contains(d.Id) && d.FactoryRefId == factoryId) .Select(d => new { d.Id, Name = d.Descr ?? d.Department }) .ToListAsync(); var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString()); return list .GroupBy(x => x.ResponsibleDeptId) .Select(g => new { deptId = g.Key, 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-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) .ToList(); } /// /// 按维度返回多系列日趋势数据。dim: object | process | occDept | respDept /// 返回: { dates, series: [{ name, data[] }] } /// public async Task GetDimTrendsAsync(long tenantId, long factoryId, string dim, int days, string? period = null) { var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period); DateTime from, toExclusive; if (periodFrom.HasValue && periodTo.HasValue) { from = periodFrom.Value; toExclusive = periodTo.Value; } else { from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90)); toExclusive = DateTime.Today.AddDays(1); } var rows = await _rep.AsQueryable() .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from && x.CreatedAt < toExclusive) .Where(x => S8ModuleCode.All.Contains(x.ModuleCode)) .Select(x => new { x.CreatedAt, // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:dim=process 改用 module_code 聚合。 x.ModuleCode, x.RelatedObjectCode, x.OccurrenceDeptId, x.ResponsibleDeptId, }) .ToListAsync(); // 生成连续日期序列(含首尾,endDate 取 toExclusive-1 与 Today 较小值避免未来日期) var endDate = new[] { toExclusive.AddDays(-1).Date, DateTime.Today }.Min(); var dates = Enumerable.Range(0, (endDate - from.Date).Days + 1) .Select(i => from.AddDays(i).Date) .ToList(); var dateLabels = dates.Select(d => d.ToString("yyyy-MM-dd")).ToList(); // 按维度取 key 函数 // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:object 维度 NULL/空白统一归入"未关联",与 byObject 口径一致; // process 维度沿用历史"未设置"作为兜底(理论不会命中:上方 Where 已限定 module_code IN S1-S7)。 // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:process 改用 module_code(process_node_code 挂起)。 Func keySelector = dim switch { "process" => r => r.ModuleCode ?? "未设置", "occDept" => r => r.OccurrenceDeptId.ToString(), "respDept" => r => r.ResponsibleDeptId.ToString(), _ => r => NormalizeRelatedObjectCode((string?)r.RelatedObjectCode), // object }; var grouped = rows .GroupBy(r => keySelector(r)) .OrderByDescending(g => g.Count()) .Take(8) // 最多取 8 个系列,避免图例过多 .ToList(); // 补全部门名 / 流程节点名 Dictionary nameMap = new(); if (dim == "process") { var nodes = await _processNodeRep.AsQueryable().ToListAsync(); nameMap = nodes.ToDictionary(n => n.Code, n => n.Name); } else if (dim is "occDept" or "respDept") { var ids = grouped.Select(g => long.TryParse(g.Key, out var id) ? id : 0).ToList(); // S8-DEPT-DISPLAY-CONSISTENCY-1-FOLLOWUP-1(P0-A-3 收尾):部门水合加 factory_ref_id 约束, // 与 GetDistributionsAsync / GetDeptBacklogAsync 同口径,避免跨 factory 同名/同 RecID 错位。 var depts = await _deptRep.AsQueryable() .Where(d => ids.Contains(d.Id) && d.FactoryRefId == factoryId) .Select(d => new { d.Id, Name = d.Descr ?? d.Department }) .ToListAsync(); nameMap = depts.ToDictionary(d => d.Id.ToString(), d => d.Name ?? d.Id.ToString()); } var series = grouped.Select(g => { var seriesName = nameMap.TryGetValue(g.Key, out var n) ? n : g.Key; var byDate = g.GroupBy(r => r.CreatedAt.Date).ToDictionary(x => x.Key, x => x.Count()); var data = dates.Select(d => byDate.TryGetValue(d, out var c) ? c : 0).ToList(); return new { name = seriesName, data }; }).ToList(); return new { dates = dateLabels, series }; } public async Task> GetQuickExceptionsAsync(long tenantId, long factoryId, string mode) { 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.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(); } }