S8DashboardService.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  6. public class S8DashboardService : ITransient
  7. {
  8. // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:byObject / dim-trends(object) 维度统一 NULL 归桶口径。
  9. // related_object_code 为 NULL/空白 → 归入"未关联",避免主图空数据误导。
  10. private const string UnlinkedObjectLabel = "未关联";
  11. private static string NormalizeRelatedObjectCode(string? relatedObjectCode) =>
  12. string.IsNullOrWhiteSpace(relatedObjectCode)
  13. ? UnlinkedObjectLabel
  14. : relatedObjectCode!.Trim();
  15. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  16. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  17. private readonly SqlSugarRepository<AdoS8ProcessNode> _processNodeRep;
  18. public S8DashboardService(
  19. SqlSugarRepository<AdoS8Exception> rep,
  20. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  21. SqlSugarRepository<AdoS8ProcessNode> processNodeRep)
  22. {
  23. _rep = rep;
  24. _deptRep = deptRep;
  25. _processNodeRep = processNodeRep;
  26. }
  27. public async Task<object> GetOverviewAsync(long tenantId, long factoryId, DateTime? beginTime = null, DateTime? endTime = null, string? severity = null, string? period = null)
  28. {
  29. // 看板顶部的"开始/结束日期"通过 beginTime/endTime 透传,与列表的 beginTime/endTime 同语义。
  30. // BUG-12:补 severity 过滤,与异常列表 API 的 severity 口径一致(精确匹配 LOW/MEDIUM/HIGH/CRITICAL)。
  31. // BUG-22:REJECTED 状态归入 pending 桶,避免从总数视角"消失";与列表 statusBucket="pending" 同步对齐。
  32. // S8-DASHBOARD-DATA-ALIGN-S1S7-1:统一只统计 module_code IN S1-S7;NULL module 的 legacy 行不参与看板 KPI。
  33. // 显式日期范围优先;未传时从 period 解析。
  34. var (periodFrom, periodTo) = (!beginTime.HasValue && !endTime.HasValue)
  35. ? S8PeriodHelper.Resolve(period)
  36. : ((DateTime?)null, (DateTime?)null);
  37. var effectiveFrom = beginTime ?? periodFrom;
  38. var effectiveTo = endTime ?? periodTo;
  39. var q = _rep.AsQueryable()
  40. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  41. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  42. .WhereIF(effectiveFrom.HasValue, x => x.CreatedAt >= effectiveFrom!.Value)
  43. .WhereIF(effectiveTo.HasValue, x => x.CreatedAt < effectiveTo!.Value)
  44. .WhereIF(!string.IsNullOrWhiteSpace(severity), x => x.Severity == severity);
  45. var total = await q.CountAsync();
  46. var pending = await q.CountAsync(x => x.Status == "NEW" || x.Status == "ASSIGNED" || x.Status == "IN_PROGRESS" || x.Status == "PENDING_VERIFICATION" || x.Status == "REJECTED");
  47. var inProgress = await q.CountAsync(x => x.Status == "IN_PROGRESS");
  48. // S8-SLA-TIMEOUT-RUNTIME-1(P3):当前超时改为运行时计算 = sla_deadline IS NOT NULL AND sla_deadline < now AND status NOT IN ('CLOSED','RECOVERED')。
  49. // timeout_flag 已降级 legacy;本批不清理历史 timeout_flag。
  50. var nowForTimeout = DateTime.Now;
  51. var timeout = await q.CountAsync(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED");
  52. var closed = await q.CountAsync(x => x.Status == "CLOSED");
  53. var todayNew = await q.CountAsync(x => x.CreatedAt >= DateTime.Today);
  54. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:critical 字段名保留以维持外部 KPI;语义切为「严重」(SERIOUS)。
  55. var seriousList = await q.Select(x => x.Severity).ToListAsync();
  56. var critical = seriousList.Count(s => S8SeverityCode.IsSerious(s));
  57. var closureRate = total > 0 ? Math.Round(closed * 100.0 / total, 1) : 0.0;
  58. // 平均处理周期(小时),仅对已闭环且有关闭时间的记录计算
  59. var closedRows = await q
  60. .Where(x => x.Status == "CLOSED" && x.ClosedAt != null)
  61. .Select(x => new { x.CreatedAt, x.ClosedAt })
  62. .ToListAsync();
  63. var avgCycleHours = closedRows.Count > 0
  64. ? Math.Round(closedRows.Average(x => (x.ClosedAt!.Value - x.CreatedAt).TotalHours), 1)
  65. : 0.0;
  66. return new { total, pending, inProgress, timeout, closed, todayNew, critical, closureRate, avgCycleHours };
  67. }
  68. public async Task<object> GetTrendsAsync(long tenantId, long factoryId, int days)
  69. {
  70. var from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90));
  71. var rows = await _rep.AsQueryable()
  72. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from)
  73. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  74. .Select(x => new { x.CreatedAt })
  75. .ToListAsync();
  76. return rows
  77. .GroupBy(x => x.CreatedAt.Date)
  78. .OrderBy(g => g.Key)
  79. .Select(g => new { date = g.Key.ToString("yyyy-MM-dd"), count = g.Count() })
  80. .ToList();
  81. }
  82. public async Task<object> GetDistributionsAsync(long tenantId, long factoryId, string? period = null)
  83. {
  84. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  85. var list = await _rep.AsQueryable()
  86. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  87. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  88. .WhereIF(periodFrom.HasValue, x => x.CreatedAt >= periodFrom!.Value)
  89. .WhereIF(periodTo.HasValue, x => x.CreatedAt < periodTo!.Value)
  90. .Select(x => new
  91. {
  92. x.Status,
  93. x.SceneCode,
  94. x.Severity,
  95. x.ResponsibleDeptId,
  96. x.OccurrenceDeptId,
  97. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:byProcess 改用 module_code 聚合。
  98. x.ModuleCode,
  99. x.RelatedObjectCode,
  100. })
  101. .ToListAsync();
  102. // 部门名称字典
  103. var allDeptIds = list.Select(x => x.ResponsibleDeptId)
  104. .Concat(list.Select(x => x.OccurrenceDeptId))
  105. .Distinct().ToList();
  106. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):部门水合加 factory_ref_id 约束,避免跨 factory 同 RecID 错位。
  107. var deptMap = await _deptRep.AsQueryable()
  108. .Where(d => allDeptIds.Contains(d.Id) && d.FactoryRefId == factoryId)
  109. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  110. .ToListAsync();
  111. var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString());
  112. // 流程节点名称字典
  113. var processNodes = await _processNodeRep.AsQueryable()
  114. .OrderBy(p => p.SortNo)
  115. .Select(p => new { p.Code, p.Name })
  116. .ToListAsync();
  117. var processDict = processNodes.ToDictionary(p => p.Code, p => p.Name);
  118. return new
  119. {
  120. byStatus = list
  121. .GroupBy(x => x.Status)
  122. .Select(g => new { key = g.Key, count = g.Count() }),
  123. // S8-DASHBOARD-BYSCENE-RESERVE-CLEAR-1:byScene 不再生成。
  124. // 业务展示主口径已切到 module_code(byProcess),保留 scene_code 仅用于建单/规则唯一性/配置 CRUD/历史追溯。
  125. // 字段保留以维持响应结构兼容;前端无消费方。
  126. byScene = Array.Empty<object>(),
  127. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:bySeverity 归一为 FOLLOW/SERIOUS 两桶。
  128. bySeverity = list
  129. .GroupBy(x => S8SeverityCode.Normalize(x.Severity))
  130. .Select(g => new { key = g.Key, count = g.Count() }),
  131. byDept = list
  132. .GroupBy(x => x.ResponsibleDeptId)
  133. .Select(g => new
  134. {
  135. key = g.Key,
  136. deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
  137. count = g.Count(),
  138. }),
  139. byOccurrenceDept = list
  140. .GroupBy(x => x.OccurrenceDeptId)
  141. .Select(g => new
  142. {
  143. key = g.Key,
  144. deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
  145. count = g.Count(),
  146. }),
  147. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段「流程环节」按 module_code 聚合
  148. // (process_node_code 已挂起,留给未来更细流程节点)。
  149. // module_code 已经过 S1-S7 过滤(见 line 87 Where(S8ModuleCode.All.Contains(...))),无需再 NULL 兜底。
  150. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:byProcess 增加 followCount/seriousCount 两段,
  151. // 供前端「流程环节」黄红堆叠柱使用;count = follow + serious。排序按 S1-S7 自然字典序固定。
  152. byProcess = list
  153. .GroupBy(x => x.ModuleCode)
  154. .Select(g => new
  155. {
  156. key = g.Key,
  157. nodeName = processDict.GetValueOrDefault(g.Key, g.Key),
  158. count = g.Count(),
  159. followCount = g.Count(x => S8SeverityCode.IsFollow(x.Severity)),
  160. seriousCount = g.Count(x => S8SeverityCode.IsSerious(x.Severity)),
  161. })
  162. .OrderBy(r => r.key),
  163. byObject = list
  164. // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:不再过滤 NULL,与 dim-trends 口径统一;
  165. // related_object_code 为空时归入"未关联"桶。
  166. .GroupBy(x => NormalizeRelatedObjectCode(x.RelatedObjectCode))
  167. .OrderByDescending(g => g.Count())
  168. .Take(20)
  169. .Select(g => new { key = g.Key, count = g.Count() }),
  170. };
  171. }
  172. public async Task<object> GetDeptBacklogAsync(long tenantId, long factoryId, string? period = null)
  173. {
  174. var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" };
  175. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  176. // S8-SLA-TIMEOUT-RUNTIME-1(P3):投影 SlaDeadline 替代 TimeoutFlag;timeout 由内存计算(与 GetSummaryAsync 一致 now)。
  177. var list = await _rep.AsQueryable()
  178. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  179. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  180. .WhereIF(periodFrom.HasValue, x => x.CreatedAt >= periodFrom!.Value)
  181. .WhereIF(periodTo.HasValue, x => x.CreatedAt < periodTo!.Value)
  182. .Select(x => new { x.ResponsibleDeptId, x.Status, x.SlaDeadline })
  183. .ToListAsync();
  184. var nowForTimeout = DateTime.Now;
  185. var deptIds = list.Select(x => x.ResponsibleDeptId).Distinct().ToList();
  186. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):部门水合加 factory_ref_id 约束。
  187. var deptMap = await _deptRep.AsQueryable()
  188. .Where(d => deptIds.Contains(d.Id) && d.FactoryRefId == factoryId)
  189. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  190. .ToListAsync();
  191. var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString());
  192. return list
  193. .GroupBy(x => x.ResponsibleDeptId)
  194. .Select(g => new
  195. {
  196. deptId = g.Key,
  197. deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
  198. pending = g.Count(x => pendingStatuses.Contains(x.Status)),
  199. inProgress = g.Count(x => x.Status == "IN_PROGRESS"),
  200. // S8-SLA-TIMEOUT-RUNTIME-1:当前超时 = sla_deadline 在 now 之前 AND 未 CLOSED/RECOVERED。
  201. timeout = g.Count(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED"),
  202. total = g.Count(),
  203. })
  204. .OrderByDescending(x => x.pending)
  205. .ToList();
  206. }
  207. /// <summary>
  208. /// 按维度返回多系列日趋势数据。dim: object | process | occDept | respDept
  209. /// 返回: { dates, series: [{ name, data[] }] }
  210. /// </summary>
  211. public async Task<object> GetDimTrendsAsync(long tenantId, long factoryId, string dim, int days)
  212. {
  213. var from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90));
  214. var rows = await _rep.AsQueryable()
  215. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from)
  216. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  217. .Select(x => new
  218. {
  219. x.CreatedAt,
  220. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:dim=process 改用 module_code 聚合。
  221. x.ModuleCode,
  222. x.RelatedObjectCode,
  223. x.OccurrenceDeptId,
  224. x.ResponsibleDeptId,
  225. })
  226. .ToListAsync();
  227. // 生成连续日期序列
  228. var dates = Enumerable.Range(0, (DateTime.Today - from).Days + 1)
  229. .Select(i => from.AddDays(i).Date)
  230. .ToList();
  231. var dateLabels = dates.Select(d => d.ToString("yyyy-MM-dd")).ToList();
  232. // 按维度取 key 函数
  233. // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:object 维度 NULL/空白统一归入"未关联",与 byObject 口径一致;
  234. // process 维度沿用历史"未设置"作为兜底(理论不会命中:上方 Where 已限定 module_code IN S1-S7)。
  235. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:process 改用 module_code(process_node_code 挂起)。
  236. Func<dynamic, string> keySelector = dim switch
  237. {
  238. "process" => r => r.ModuleCode ?? "未设置",
  239. "occDept" => r => r.OccurrenceDeptId.ToString(),
  240. "respDept" => r => r.ResponsibleDeptId.ToString(),
  241. _ => r => NormalizeRelatedObjectCode((string?)r.RelatedObjectCode), // object
  242. };
  243. var grouped = rows
  244. .GroupBy(r => keySelector(r))
  245. .OrderByDescending(g => g.Count())
  246. .Take(8) // 最多取 8 个系列,避免图例过多
  247. .ToList();
  248. // 补全部门名 / 流程节点名
  249. Dictionary<string, string> nameMap = new();
  250. if (dim == "process")
  251. {
  252. var nodes = await _processNodeRep.AsQueryable().ToListAsync();
  253. nameMap = nodes.ToDictionary(n => n.Code, n => n.Name);
  254. }
  255. else if (dim is "occDept" or "respDept")
  256. {
  257. var ids = grouped.Select(g => long.TryParse(g.Key, out var id) ? id : 0).ToList();
  258. // S8-DEPT-DISPLAY-CONSISTENCY-1-FOLLOWUP-1(P0-A-3 收尾):部门水合加 factory_ref_id 约束,
  259. // 与 GetDistributionsAsync / GetDeptBacklogAsync 同口径,避免跨 factory 同名/同 RecID 错位。
  260. var depts = await _deptRep.AsQueryable()
  261. .Where(d => ids.Contains(d.Id) && d.FactoryRefId == factoryId)
  262. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  263. .ToListAsync();
  264. nameMap = depts.ToDictionary(d => d.Id.ToString(), d => d.Name ?? d.Id.ToString());
  265. }
  266. var series = grouped.Select(g =>
  267. {
  268. var seriesName = nameMap.TryGetValue(g.Key, out var n) ? n : g.Key;
  269. var byDate = g.GroupBy(r => r.CreatedAt.Date).ToDictionary(x => x.Key, x => x.Count());
  270. var data = dates.Select(d => byDate.TryGetValue(d, out var c) ? c : 0).ToList();
  271. return new { name = seriesName, data };
  272. }).ToList();
  273. return new { dates = dateLabels, series };
  274. }
  275. public async Task<List<AdoS8Exception>> GetQuickExceptionsAsync(long tenantId, long factoryId, string mode)
  276. {
  277. var q = _rep.AsQueryable()
  278. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  279. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode));
  280. // S8-SLA-TIMEOUT-RUNTIME-1(P3):timeout 模式按 sla_deadline 在线计算筛 + 升序。
  281. var nowForTimeout = DateTime.Now;
  282. var ordered = mode switch
  283. {
  284. "high-priority" => q.OrderBy(x => x.PriorityScore, OrderByType.Desc),
  285. "timeout" => q.Where(x => x.SlaDeadline != null && x.SlaDeadline < nowForTimeout && x.Status != "CLOSED" && x.Status != "RECOVERED").OrderBy(x => x.SlaDeadline),
  286. _ => q.OrderBy(x => x.CreatedAt, OrderByType.Desc),
  287. };
  288. return await ordered.Take(20).ToListAsync();
  289. }
  290. }