S8DashboardService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  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)
  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. var q = _rep.AsQueryable()
  34. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  35. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  36. .WhereIF(beginTime.HasValue, x => x.CreatedAt >= beginTime!.Value)
  37. .WhereIF(endTime.HasValue, x => x.CreatedAt <= endTime!.Value)
  38. .WhereIF(!string.IsNullOrWhiteSpace(severity), x => x.Severity == severity);
  39. var total = await q.CountAsync();
  40. var pending = await q.CountAsync(x => x.Status == "NEW" || x.Status == "ASSIGNED" || x.Status == "IN_PROGRESS" || x.Status == "PENDING_VERIFICATION" || x.Status == "REJECTED");
  41. var inProgress = await q.CountAsync(x => x.Status == "IN_PROGRESS");
  42. var timeout = await q.CountAsync(x => x.TimeoutFlag);
  43. var closed = await q.CountAsync(x => x.Status == "CLOSED");
  44. var todayNew = await q.CountAsync(x => x.CreatedAt >= DateTime.Today);
  45. var critical = await q.CountAsync(x => x.Severity == "CRITICAL");
  46. var closureRate = total > 0 ? Math.Round(closed * 100.0 / total, 1) : 0.0;
  47. // 平均处理周期(小时),仅对已闭环且有关闭时间的记录计算
  48. var closedRows = await q
  49. .Where(x => x.Status == "CLOSED" && x.ClosedAt != null)
  50. .Select(x => new { x.CreatedAt, x.ClosedAt })
  51. .ToListAsync();
  52. var avgCycleHours = closedRows.Count > 0
  53. ? Math.Round(closedRows.Average(x => (x.ClosedAt!.Value - x.CreatedAt).TotalHours), 1)
  54. : 0.0;
  55. return new { total, pending, inProgress, timeout, closed, todayNew, critical, closureRate, avgCycleHours };
  56. }
  57. public async Task<object> GetTrendsAsync(long tenantId, long factoryId, int days)
  58. {
  59. var from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90));
  60. var rows = await _rep.AsQueryable()
  61. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from)
  62. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  63. .Select(x => new { x.CreatedAt })
  64. .ToListAsync();
  65. return rows
  66. .GroupBy(x => x.CreatedAt.Date)
  67. .OrderBy(g => g.Key)
  68. .Select(g => new { date = g.Key.ToString("yyyy-MM-dd"), count = g.Count() })
  69. .ToList();
  70. }
  71. public async Task<object> GetDistributionsAsync(long tenantId, long factoryId)
  72. {
  73. var list = await _rep.AsQueryable()
  74. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  75. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  76. .Select(x => new
  77. {
  78. x.Status,
  79. x.SceneCode,
  80. x.Severity,
  81. x.ResponsibleDeptId,
  82. x.OccurrenceDeptId,
  83. x.ProcessNodeCode,
  84. x.RelatedObjectCode,
  85. })
  86. .ToListAsync();
  87. // 部门名称字典
  88. var allDeptIds = list.Select(x => x.ResponsibleDeptId)
  89. .Concat(list.Select(x => x.OccurrenceDeptId))
  90. .Distinct().ToList();
  91. var deptMap = await _deptRep.AsQueryable()
  92. .Where(d => allDeptIds.Contains(d.Id))
  93. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  94. .ToListAsync();
  95. var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString());
  96. // 流程节点名称字典
  97. var processNodes = await _processNodeRep.AsQueryable()
  98. .OrderBy(p => p.SortNo)
  99. .Select(p => new { p.Code, p.Name })
  100. .ToListAsync();
  101. var processDict = processNodes.ToDictionary(p => p.Code, p => p.Name);
  102. return new
  103. {
  104. byStatus = list
  105. .GroupBy(x => x.Status)
  106. .Select(g => new { key = g.Key, count = g.Count() }),
  107. byScene = list
  108. .GroupBy(x => x.SceneCode)
  109. .Select(g => new { key = g.Key, count = g.Count() }),
  110. bySeverity = list
  111. .GroupBy(x => x.Severity)
  112. .Select(g => new { key = g.Key, count = g.Count() }),
  113. byDept = list
  114. .GroupBy(x => x.ResponsibleDeptId)
  115. .Select(g => new
  116. {
  117. key = g.Key,
  118. deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
  119. count = g.Count(),
  120. }),
  121. byOccurrenceDept = list
  122. .GroupBy(x => x.OccurrenceDeptId)
  123. .Select(g => new
  124. {
  125. key = g.Key,
  126. deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
  127. count = g.Count(),
  128. }),
  129. byProcess = list
  130. .Where(x => x.ProcessNodeCode != null)
  131. .GroupBy(x => x.ProcessNodeCode!)
  132. .Select(g => new
  133. {
  134. key = g.Key,
  135. nodeName = processDict.GetValueOrDefault(g.Key, g.Key),
  136. count = g.Count(),
  137. }),
  138. byObject = list
  139. // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:不再过滤 NULL,与 dim-trends 口径统一;
  140. // related_object_code 为空时归入"未关联"桶。
  141. .GroupBy(x => NormalizeRelatedObjectCode(x.RelatedObjectCode))
  142. .OrderByDescending(g => g.Count())
  143. .Take(20)
  144. .Select(g => new { key = g.Key, count = g.Count() }),
  145. };
  146. }
  147. public async Task<object> GetDeptBacklogAsync(long tenantId, long factoryId)
  148. {
  149. var pendingStatuses = new[] { "NEW", "ASSIGNED", "IN_PROGRESS" };
  150. var list = await _rep.AsQueryable()
  151. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  152. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  153. .Select(x => new { x.ResponsibleDeptId, x.Status, x.TimeoutFlag })
  154. .ToListAsync();
  155. var deptIds = list.Select(x => x.ResponsibleDeptId).Distinct().ToList();
  156. var deptMap = await _deptRep.AsQueryable()
  157. .Where(d => deptIds.Contains(d.Id))
  158. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  159. .ToListAsync();
  160. var deptDict = deptMap.ToDictionary(d => d.Id, d => d.Name ?? d.Id.ToString());
  161. return list
  162. .GroupBy(x => x.ResponsibleDeptId)
  163. .Select(g => new
  164. {
  165. deptId = g.Key,
  166. deptName = deptDict.GetValueOrDefault(g.Key, g.Key.ToString()),
  167. pending = g.Count(x => pendingStatuses.Contains(x.Status)),
  168. inProgress = g.Count(x => x.Status == "IN_PROGRESS"),
  169. timeout = g.Count(x => x.TimeoutFlag),
  170. total = g.Count(),
  171. })
  172. .OrderByDescending(x => x.pending)
  173. .ToList();
  174. }
  175. /// <summary>
  176. /// 按维度返回多系列日趋势数据。dim: object | process | occDept | respDept
  177. /// 返回: { dates, series: [{ name, data[] }] }
  178. /// </summary>
  179. public async Task<object> GetDimTrendsAsync(long tenantId, long factoryId, string dim, int days)
  180. {
  181. var from = DateTime.Today.AddDays(-Math.Clamp(days, 1, 90));
  182. var rows = await _rep.AsQueryable()
  183. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted && x.CreatedAt >= from)
  184. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode))
  185. .Select(x => new
  186. {
  187. x.CreatedAt,
  188. x.ProcessNodeCode,
  189. x.RelatedObjectCode,
  190. x.OccurrenceDeptId,
  191. x.ResponsibleDeptId,
  192. })
  193. .ToListAsync();
  194. // 生成连续日期序列
  195. var dates = Enumerable.Range(0, (DateTime.Today - from).Days + 1)
  196. .Select(i => from.AddDays(i).Date)
  197. .ToList();
  198. var dateLabels = dates.Select(d => d.ToString("yyyy-MM-dd")).ToList();
  199. // 按维度取 key 函数
  200. // S8-DASHBOARD-BYOBJECT-NULL-BUCKET-1:object 维度 NULL/空白统一归入"未关联",与 byObject 口径一致;
  201. // process 维度沿用历史"未设置",本次不扩大。
  202. Func<dynamic, string> keySelector = dim switch
  203. {
  204. "process" => r => r.ProcessNodeCode ?? "未设置",
  205. "occDept" => r => r.OccurrenceDeptId.ToString(),
  206. "respDept" => r => r.ResponsibleDeptId.ToString(),
  207. _ => r => NormalizeRelatedObjectCode((string?)r.RelatedObjectCode), // object
  208. };
  209. var grouped = rows
  210. .GroupBy(r => keySelector(r))
  211. .OrderByDescending(g => g.Count())
  212. .Take(8) // 最多取 8 个系列,避免图例过多
  213. .ToList();
  214. // 补全部门名 / 流程节点名
  215. Dictionary<string, string> nameMap = new();
  216. if (dim == "process")
  217. {
  218. var nodes = await _processNodeRep.AsQueryable().ToListAsync();
  219. nameMap = nodes.ToDictionary(n => n.Code, n => n.Name);
  220. }
  221. else if (dim is "occDept" or "respDept")
  222. {
  223. var ids = grouped.Select(g => long.TryParse(g.Key, out var id) ? id : 0).ToList();
  224. var depts = await _deptRep.AsQueryable()
  225. .Where(d => ids.Contains(d.Id))
  226. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  227. .ToListAsync();
  228. nameMap = depts.ToDictionary(d => d.Id.ToString(), d => d.Name ?? d.Id.ToString());
  229. }
  230. var series = grouped.Select(g =>
  231. {
  232. var seriesName = nameMap.TryGetValue(g.Key, out var n) ? n : g.Key;
  233. var byDate = g.GroupBy(r => r.CreatedAt.Date).ToDictionary(x => x.Key, x => x.Count());
  234. var data = dates.Select(d => byDate.TryGetValue(d, out var c) ? c : 0).ToList();
  235. return new { name = seriesName, data };
  236. }).ToList();
  237. return new { dates = dateLabels, series };
  238. }
  239. public async Task<List<AdoS8Exception>> GetQuickExceptionsAsync(long tenantId, long factoryId, string mode)
  240. {
  241. var q = _rep.AsQueryable()
  242. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted)
  243. .Where(x => S8ModuleCode.All.Contains(x.ModuleCode));
  244. var ordered = mode switch
  245. {
  246. "high-priority" => q.OrderBy(x => x.PriorityScore, OrderByType.Desc),
  247. "timeout" => q.Where(x => x.TimeoutFlag).OrderBy(x => x.SlaDeadline),
  248. _ => q.OrderBy(x => x.CreatedAt, OrderByType.Desc),
  249. };
  250. return await ordered.Take(20).ToListAsync();
  251. }
  252. }