FlowStatisticsService.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. namespace Admin.NET.Plugin.ApprovalFlow.Service;
  2. /// <summary>
  3. /// 审批流程统计服务(P3-15 节点级统计)
  4. /// </summary>
  5. [ApiDescriptionSettings(ApprovalFlowConst.GroupName, Order = 50)]
  6. public class FlowStatisticsService : IDynamicApiController, ITransient
  7. {
  8. private readonly SqlSugarRepository<ApprovalFlowInstance> _instanceRep;
  9. private readonly SqlSugarRepository<ApprovalFlowTask> _taskRep;
  10. private readonly SqlSugarRepository<ApprovalFlowCompletedNode> _completedNodeRep;
  11. private readonly SqlSugarRepository<ApprovalFlowLog> _logRep;
  12. private readonly SqlSugarRepository<ApprovalFlow> _flowRep;
  13. private readonly SqlSugarRepository<ApprovalBizType> _bizTypeRep;
  14. public FlowStatisticsService(
  15. SqlSugarRepository<ApprovalFlowInstance> instanceRep,
  16. SqlSugarRepository<ApprovalFlowTask> taskRep,
  17. SqlSugarRepository<ApprovalFlowCompletedNode> completedNodeRep,
  18. SqlSugarRepository<ApprovalFlowLog> logRep,
  19. SqlSugarRepository<ApprovalFlow> flowRep,
  20. SqlSugarRepository<ApprovalBizType> bizTypeRep)
  21. {
  22. _instanceRep = instanceRep;
  23. _taskRep = taskRep;
  24. _completedNodeRep = completedNodeRep;
  25. _logRep = logRep;
  26. _flowRep = flowRep;
  27. _bizTypeRep = bizTypeRep;
  28. }
  29. /// <summary>
  30. /// 概览卡片 + 逐日趋势图
  31. /// </summary>
  32. [HttpPost]
  33. [ApiDescriptionSettings(Name = "Overview")]
  34. [DisplayName("审批概览")]
  35. public async Task<FlowOverviewOutput> Overview(FlowStatisticsInput input)
  36. {
  37. var (start, end) = NormalizeRange(input);
  38. var instances = await BuildInstanceQuery(input)
  39. .Select(i => new InstanceLite { Id = i.Id, Status = i.Status, StartTime = i.StartTime, EndTime = i.EndTime })
  40. .ToListAsync();
  41. var total = instances.Count;
  42. var completed = instances.Count(i => i.Status == FlowInstanceStatusEnum.Approved);
  43. var completedDurations = instances
  44. .Where(i => i.Status == FlowInstanceStatusEnum.Approved && i.EndTime.HasValue)
  45. .Select(i => (decimal)(i.EndTime!.Value - i.StartTime).TotalHours)
  46. .ToList();
  47. var avgHours = completedDurations.Count == 0 ? 0m : completedDurations.Average();
  48. int timeoutCount = 0;
  49. int processedTaskCount = 0;
  50. if (instances.Count > 0)
  51. {
  52. var instanceIds = instances.Select(i => i.Id).ToList();
  53. timeoutCount = await _logRep.AsQueryable()
  54. .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout)
  55. .CountAsync();
  56. processedTaskCount = await _taskRep.AsQueryable()
  57. .Where(t => instanceIds.Contains(t.InstanceId)
  58. && t.Status != FlowTaskStatusEnum.Pending
  59. && t.Status != FlowTaskStatusEnum.Cancelled)
  60. .CountAsync();
  61. }
  62. var dailyStart = new Dictionary<string, int>();
  63. foreach (var i in instances)
  64. AddCount(dailyStart, i.StartTime.Date.ToString("yyyy-MM-dd"));
  65. var dailyComp = new Dictionary<string, int>();
  66. foreach (var i in instances.Where(x => x.EndTime.HasValue && x.Status == FlowInstanceStatusEnum.Approved))
  67. AddCount(dailyComp, i.EndTime!.Value.Date.ToString("yyyy-MM-dd"));
  68. var trend = new List<FlowTrendItem>();
  69. for (var d = start.Date; d <= end.Date; d = d.AddDays(1))
  70. {
  71. var key = d.ToString("yyyy-MM-dd");
  72. trend.Add(new FlowTrendItem
  73. {
  74. Date = key,
  75. StartedCount = dailyStart.GetValueOrDefault(key),
  76. CompletedCount = dailyComp.GetValueOrDefault(key),
  77. });
  78. }
  79. return new FlowOverviewOutput
  80. {
  81. TotalInstances = total,
  82. CompletedInstances = completed,
  83. CompletionRate = total == 0 ? 0m : Math.Round((decimal)completed / total, 4),
  84. AvgDurationHours = Math.Round(avgHours, 2),
  85. TimeoutTaskCount = timeoutCount,
  86. TimeoutRate = processedTaskCount == 0 ? 0m : Math.Round((decimal)timeoutCount / processedTaskCount, 4),
  87. DailyTrend = trend,
  88. };
  89. }
  90. /// <summary>
  91. /// 表①:按流程维度统计
  92. /// </summary>
  93. [HttpPost]
  94. [ApiDescriptionSettings(Name = "ByFlow")]
  95. [DisplayName("按流程统计")]
  96. public async Task<List<FlowByFlowOutput>> ByFlow(FlowStatisticsInput input)
  97. {
  98. var rows = await BuildInstanceQuery(input)
  99. .Select(i => new { i.FlowId, i.Status, i.StartTime, i.EndTime, i.BizType })
  100. .ToListAsync();
  101. if (rows.Count == 0) return new();
  102. var flowIds = rows.Select(r => r.FlowId).Distinct().ToList();
  103. var flows = await _flowRep.AsQueryable()
  104. .Where(f => flowIds.Contains(f.Id))
  105. .Select(f => new { f.Id, f.Code, f.Name, f.BizType })
  106. .ToListAsync();
  107. var flowMap = flows.ToDictionary(f => f.Id);
  108. var bizTypeCodes = rows.Select(r => r.BizType)
  109. .Where(s => !string.IsNullOrEmpty(s))
  110. .Distinct()
  111. .ToList();
  112. var bizTypeMap = new Dictionary<string, string>();
  113. if (bizTypeCodes.Count > 0)
  114. {
  115. var bizTypes = await _bizTypeRep.AsQueryable()
  116. .Where(b => bizTypeCodes.Contains(b.Code))
  117. .Select(b => new { b.Code, b.Name })
  118. .ToListAsync();
  119. bizTypeMap = bizTypes.ToDictionary(b => b.Code, b => b.Name);
  120. }
  121. return rows.GroupBy(r => r.FlowId).Select(g =>
  122. {
  123. var durations = g.Where(x => x.Status == FlowInstanceStatusEnum.Approved && x.EndTime.HasValue)
  124. .Select(x => (decimal)(x.EndTime!.Value - x.StartTime).TotalHours)
  125. .ToList();
  126. var completedCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Approved);
  127. var rejectedCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Rejected);
  128. flowMap.TryGetValue(g.Key, out var flow);
  129. var bizType = flow?.BizType ?? g.First().BizType;
  130. return new FlowByFlowOutput
  131. {
  132. FlowId = g.Key,
  133. FlowCode = flow?.Code,
  134. FlowName = flow?.Name ?? "(已删除)",
  135. BizType = bizType,
  136. BizTypeName = !string.IsNullOrEmpty(bizType) && bizTypeMap.TryGetValue(bizType!, out var bn) ? bn : null,
  137. TotalCount = g.Count(),
  138. CompletedCount = completedCount,
  139. RunningCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Running),
  140. RejectedCount = rejectedCount,
  141. PassRate = (completedCount + rejectedCount) == 0
  142. ? 0m
  143. : Math.Round((decimal)completedCount / (completedCount + rejectedCount), 4),
  144. AvgDurationHours = durations.Count == 0 ? 0m : Math.Round(durations.Average(), 2),
  145. MaxDurationHours = durations.Count == 0 ? 0m : Math.Round(durations.Max(), 2),
  146. };
  147. })
  148. .OrderByDescending(x => x.TotalCount)
  149. .ToList();
  150. }
  151. /// <summary>
  152. /// 表②:按节点维度统计(核心瓶颈分析)
  153. /// </summary>
  154. [HttpPost]
  155. [ApiDescriptionSettings(Name = "ByNode")]
  156. [DisplayName("按节点统计")]
  157. public async Task<List<FlowByNodeOutput>> ByNode(FlowStatisticsInput input)
  158. {
  159. var instances = await BuildInstanceQuery(input)
  160. .Select(i => new { i.Id, i.FlowId })
  161. .ToListAsync();
  162. if (instances.Count == 0) return new();
  163. var instanceIds = instances.Select(i => i.Id).ToList();
  164. var idToFlow = instances.ToDictionary(i => i.Id, i => i.FlowId);
  165. var completedNodes = await _completedNodeRep.AsQueryable()
  166. .Where(c => instanceIds.Contains(c.InstanceId))
  167. .Select(c => new { c.InstanceId, c.NodeId, c.NodeName, c.NodeType, c.CompletedTime })
  168. .ToListAsync();
  169. if (completedNodes.Count == 0) return new();
  170. var tasks = await _taskRep.AsQueryable()
  171. .Where(t => instanceIds.Contains(t.InstanceId))
  172. .Select(t => new { t.InstanceId, t.NodeId, t.Status, t.AssigneeId, t.AssigneeName, t.CreateTime, t.ActionTime })
  173. .ToListAsync();
  174. var autoTimeoutLogs = await _logRep.AsQueryable()
  175. .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout)
  176. .Select(l => new { l.InstanceId, l.NodeId })
  177. .ToListAsync();
  178. var timeoutCountByNode = autoTimeoutLogs
  179. .Where(l => !string.IsNullOrEmpty(l.NodeId))
  180. .GroupBy(l => (idToFlow[l.InstanceId], l.NodeId!))
  181. .ToDictionary(g => g.Key, g => g.Count());
  182. var flowIds = instances.Select(i => i.FlowId).Distinct().ToList();
  183. var flows = await _flowRep.AsQueryable()
  184. .Where(f => flowIds.Contains(f.Id))
  185. .Select(f => new { f.Id, f.Code, f.Name })
  186. .ToListAsync();
  187. var flowDict = flows.ToDictionary(f => f.Id);
  188. var result = new List<FlowByNodeOutput>();
  189. foreach (var grp in completedNodes.GroupBy(c => (FlowId: idToFlow[c.InstanceId], c.NodeId)))
  190. {
  191. var flowId = grp.Key.FlowId;
  192. var nodeId = grp.Key.NodeId;
  193. var sample = grp.First();
  194. var nodeTasks = tasks
  195. .Where(t => t.NodeId == nodeId && idToFlow[t.InstanceId] == flowId
  196. && (t.Status == FlowTaskStatusEnum.Approved
  197. || t.Status == FlowTaskStatusEnum.Rejected
  198. || t.Status == FlowTaskStatusEnum.Transferred))
  199. .ToList();
  200. decimal avgHours = 0, maxHours = 0;
  201. string? longest = null, shortest = null;
  202. var withAction = nodeTasks.Where(t => t.ActionTime.HasValue).ToList();
  203. if (withAction.Count > 0)
  204. {
  205. var durations = withAction
  206. .Select(t => (decimal)(t.ActionTime!.Value - t.CreateTime).TotalHours)
  207. .ToList();
  208. avgHours = Math.Round(durations.Average(), 2);
  209. maxHours = Math.Round(durations.Max(), 2);
  210. var longestTask = withAction
  211. .OrderByDescending(t => (t.ActionTime!.Value - t.CreateTime).TotalHours)
  212. .First();
  213. var shortestTask = withAction
  214. .OrderBy(t => (t.ActionTime!.Value - t.CreateTime).TotalHours)
  215. .First();
  216. longest = longestTask.AssigneeName;
  217. shortest = shortestTask.AssigneeName;
  218. }
  219. var approveCount = nodeTasks.Count(t => t.Status == FlowTaskStatusEnum.Approved);
  220. var rejectCount = nodeTasks.Count(t => t.Status == FlowTaskStatusEnum.Rejected);
  221. timeoutCountByNode.TryGetValue((flowId, nodeId), out var timeoutCount);
  222. var pending = tasks.Count(t => t.NodeId == nodeId && idToFlow[t.InstanceId] == flowId
  223. && t.Status == FlowTaskStatusEnum.Pending);
  224. flowDict.TryGetValue(flowId, out var flow);
  225. result.Add(new FlowByNodeOutput
  226. {
  227. FlowId = flowId,
  228. FlowCode = flow?.Code,
  229. FlowName = flow?.Name ?? "(已删除)",
  230. NodeId = nodeId,
  231. NodeName = sample.NodeName,
  232. NodeType = sample.NodeType,
  233. FlowCount = grp.Count(),
  234. AvgDurationHours = avgHours,
  235. MaxDurationHours = maxHours,
  236. ApproveCount = approveCount,
  237. RejectCount = rejectCount,
  238. RejectRate = (approveCount + rejectCount) == 0
  239. ? 0m
  240. : Math.Round((decimal)rejectCount / (approveCount + rejectCount), 4),
  241. TimeoutCount = timeoutCount,
  242. PendingCount = pending,
  243. LongestApproverName = longest,
  244. ShortestApproverName = shortest,
  245. });
  246. }
  247. return result.OrderByDescending(x => x.AvgDurationHours).ToList();
  248. }
  249. /// <summary>
  250. /// 表③:按审批人维度统计
  251. /// </summary>
  252. [HttpPost]
  253. [ApiDescriptionSettings(Name = "ByApprover")]
  254. [DisplayName("按审批人统计")]
  255. public async Task<List<FlowByApproverOutput>> ByApprover(FlowStatisticsInput input)
  256. {
  257. var instanceIds = await BuildInstanceQuery(input).Select(i => i.Id).ToListAsync();
  258. if (instanceIds.Count == 0) return new();
  259. var tasks = await _taskRep.AsQueryable()
  260. .Where(t => instanceIds.Contains(t.InstanceId) && t.AssigneeId > 0)
  261. .Select(t => new { t.InstanceId, t.NodeId, t.Status, t.AssigneeId, t.AssigneeName, t.CreateTime, t.ActionTime })
  262. .ToListAsync();
  263. if (tasks.Count == 0) return new();
  264. var approverIds = tasks.Select(t => t.AssigneeId).Distinct().ToList();
  265. var orgRows = await _instanceRep.Context.Queryable<SysUser>()
  266. .LeftJoin<SysOrg>((u, o) => u.OrgId == o.Id)
  267. .Where(u => approverIds.Contains(u.Id))
  268. .Select((u, o) => new { UserId = u.Id, OrgName = o.Name })
  269. .ToListAsync();
  270. var orgDict = orgRows.ToDictionary(x => x.UserId, x => x.OrgName);
  271. var timeoutLogs = await _logRep.AsQueryable()
  272. .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout)
  273. .Select(l => new { l.InstanceId, l.NodeId })
  274. .ToListAsync();
  275. var timeoutKey = timeoutLogs
  276. .Where(l => !string.IsNullOrEmpty(l.NodeId))
  277. .Select(l => (l.InstanceId, l.NodeId))
  278. .ToHashSet();
  279. return tasks.GroupBy(t => t.AssigneeId).Select(g =>
  280. {
  281. var completed = g.Where(t => t.Status == FlowTaskStatusEnum.Approved
  282. || t.Status == FlowTaskStatusEnum.Rejected
  283. || t.Status == FlowTaskStatusEnum.Transferred)
  284. .ToList();
  285. var durations = completed.Where(t => t.ActionTime.HasValue)
  286. .Select(t => (decimal)(t.ActionTime!.Value - t.CreateTime).TotalHours)
  287. .ToList();
  288. var timeoutCount = g.Count(t => timeoutKey.Contains((t.InstanceId, t.NodeId)));
  289. return new FlowByApproverOutput
  290. {
  291. ApproverId = g.Key,
  292. ApproverName = g.First().AssigneeName ?? "",
  293. OrgName = orgDict.GetValueOrDefault(g.Key),
  294. TotalAssigned = g.Count(),
  295. CompletedCount = completed.Count,
  296. PendingCount = g.Count(t => t.Status == FlowTaskStatusEnum.Pending),
  297. AvgProcessHours = durations.Count == 0 ? 0m : Math.Round(durations.Average(), 2),
  298. TimeoutCount = timeoutCount,
  299. ApproveCount = g.Count(t => t.Status == FlowTaskStatusEnum.Approved),
  300. RejectCount = g.Count(t => t.Status == FlowTaskStatusEnum.Rejected),
  301. TransferCount = g.Count(t => t.Status == FlowTaskStatusEnum.Transferred),
  302. };
  303. })
  304. .OrderByDescending(x => x.TotalAssigned)
  305. .ToList();
  306. }
  307. /// <summary>
  308. /// Top 10 最慢节点(柱图数据,仅包含 userTask)
  309. /// </summary>
  310. [HttpPost]
  311. [ApiDescriptionSettings(Name = "TopSlowNodes")]
  312. [DisplayName("最慢节点Top10")]
  313. public async Task<List<FlowTopSlowNodeItem>> TopSlowNodes(FlowStatisticsInput input)
  314. {
  315. var nodes = await ByNode(input);
  316. return nodes
  317. .Where(n => n.AvgDurationHours > 0
  318. && (n.NodeType == null || n.NodeType == "bpmn:userTask"))
  319. .OrderByDescending(n => n.AvgDurationHours)
  320. .Take(10)
  321. .Select(n => new FlowTopSlowNodeItem
  322. {
  323. FlowName = n.FlowName,
  324. NodeName = n.NodeName ?? n.NodeId,
  325. AvgDurationHours = n.AvgDurationHours,
  326. FlowCount = n.FlowCount,
  327. })
  328. .ToList();
  329. }
  330. // ───────────────── helpers ─────────────────
  331. private (DateTime start, DateTime end) NormalizeRange(FlowStatisticsInput input)
  332. {
  333. var end = input.EndTime ?? DateTime.Now;
  334. var start = input.StartTime ?? end.AddDays(-30);
  335. if (start > end) (start, end) = (end, start);
  336. return (start, end);
  337. }
  338. private ISugarQueryable<ApprovalFlowInstance> BuildInstanceQuery(FlowStatisticsInput input)
  339. {
  340. var (start, end) = NormalizeRange(input);
  341. var q = _instanceRep.AsQueryable()
  342. .Where(i => i.StartTime >= start && i.StartTime <= end);
  343. if (input.FlowIds != null && input.FlowIds.Count > 0)
  344. q = q.Where(i => input.FlowIds.Contains(i.FlowId));
  345. if (input.BizTypes != null && input.BizTypes.Count > 0)
  346. q = q.Where(i => input.BizTypes.Contains(i.BizType));
  347. if (input.InitiatorOrgIds != null && input.InitiatorOrgIds.Count > 0)
  348. q = q.Where(i => input.InitiatorOrgIds.Contains(i.OrgId));
  349. return q;
  350. }
  351. private static void AddCount(Dictionary<string, int> dict, string key)
  352. {
  353. dict[key] = dict.GetValueOrDefault(key) + 1;
  354. }
  355. private class InstanceLite
  356. {
  357. public long Id { get; set; }
  358. public FlowInstanceStatusEnum Status { get; set; }
  359. public DateTime StartTime { get; set; }
  360. public DateTime? EndTime { get; set; }
  361. }
  362. }