namespace Admin.NET.Plugin.ApprovalFlow.Service; /// /// 审批流程统计服务(P3-15 节点级统计) /// [ApiDescriptionSettings(ApprovalFlowConst.GroupName, Order = 50)] public class FlowStatisticsService : IDynamicApiController, ITransient { private readonly SqlSugarRepository _instanceRep; private readonly SqlSugarRepository _taskRep; private readonly SqlSugarRepository _completedNodeRep; private readonly SqlSugarRepository _logRep; private readonly SqlSugarRepository _flowRep; private readonly SqlSugarRepository _bizTypeRep; public FlowStatisticsService( SqlSugarRepository instanceRep, SqlSugarRepository taskRep, SqlSugarRepository completedNodeRep, SqlSugarRepository logRep, SqlSugarRepository flowRep, SqlSugarRepository bizTypeRep) { _instanceRep = instanceRep; _taskRep = taskRep; _completedNodeRep = completedNodeRep; _logRep = logRep; _flowRep = flowRep; _bizTypeRep = bizTypeRep; } /// /// 概览卡片 + 逐日趋势图 /// [HttpPost] [ApiDescriptionSettings(Name = "Overview")] [DisplayName("审批概览")] public async Task Overview(FlowStatisticsInput input) { var (start, end) = NormalizeRange(input); var instances = await BuildInstanceQuery(input) .Select(i => new InstanceLite { Id = i.Id, Status = i.Status, StartTime = i.StartTime, EndTime = i.EndTime }) .ToListAsync(); var total = instances.Count; var completed = instances.Count(i => i.Status == FlowInstanceStatusEnum.Approved); var completedDurations = instances .Where(i => i.Status == FlowInstanceStatusEnum.Approved && i.EndTime.HasValue) .Select(i => (decimal)(i.EndTime!.Value - i.StartTime).TotalHours) .ToList(); var avgHours = completedDurations.Count == 0 ? 0m : completedDurations.Average(); int timeoutCount = 0; int processedTaskCount = 0; if (instances.Count > 0) { var instanceIds = instances.Select(i => i.Id).ToList(); timeoutCount = await _logRep.AsQueryable() .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout) .CountAsync(); processedTaskCount = await _taskRep.AsQueryable() .Where(t => instanceIds.Contains(t.InstanceId) && t.Status != FlowTaskStatusEnum.Pending && t.Status != FlowTaskStatusEnum.Cancelled) .CountAsync(); } var dailyStart = new Dictionary(); foreach (var i in instances) AddCount(dailyStart, i.StartTime.Date.ToString("yyyy-MM-dd")); var dailyComp = new Dictionary(); foreach (var i in instances.Where(x => x.EndTime.HasValue && x.Status == FlowInstanceStatusEnum.Approved)) AddCount(dailyComp, i.EndTime!.Value.Date.ToString("yyyy-MM-dd")); var trend = new List(); for (var d = start.Date; d <= end.Date; d = d.AddDays(1)) { var key = d.ToString("yyyy-MM-dd"); trend.Add(new FlowTrendItem { Date = key, StartedCount = dailyStart.GetValueOrDefault(key), CompletedCount = dailyComp.GetValueOrDefault(key), }); } return new FlowOverviewOutput { TotalInstances = total, CompletedInstances = completed, CompletionRate = total == 0 ? 0m : Math.Round((decimal)completed / total, 4), AvgDurationHours = Math.Round(avgHours, 2), TimeoutTaskCount = timeoutCount, TimeoutRate = processedTaskCount == 0 ? 0m : Math.Round((decimal)timeoutCount / processedTaskCount, 4), DailyTrend = trend, }; } /// /// 表①:按流程维度统计 /// [HttpPost] [ApiDescriptionSettings(Name = "ByFlow")] [DisplayName("按流程统计")] public async Task> ByFlow(FlowStatisticsInput input) { var rows = await BuildInstanceQuery(input) .Select(i => new { i.FlowId, i.Status, i.StartTime, i.EndTime, i.BizType }) .ToListAsync(); if (rows.Count == 0) return new(); var flowIds = rows.Select(r => r.FlowId).Distinct().ToList(); var flows = await _flowRep.AsQueryable() .Where(f => flowIds.Contains(f.Id)) .Select(f => new { f.Id, f.Code, f.Name, f.BizType }) .ToListAsync(); var flowMap = flows.ToDictionary(f => f.Id); var bizTypeCodes = rows.Select(r => r.BizType) .Where(s => !string.IsNullOrEmpty(s)) .Distinct() .ToList(); var bizTypeMap = new Dictionary(); if (bizTypeCodes.Count > 0) { var bizTypes = await _bizTypeRep.AsQueryable() .Where(b => bizTypeCodes.Contains(b.Code)) .Select(b => new { b.Code, b.Name }) .ToListAsync(); bizTypeMap = bizTypes.ToDictionary(b => b.Code, b => b.Name); } return rows.GroupBy(r => r.FlowId).Select(g => { var durations = g.Where(x => x.Status == FlowInstanceStatusEnum.Approved && x.EndTime.HasValue) .Select(x => (decimal)(x.EndTime!.Value - x.StartTime).TotalHours) .ToList(); var completedCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Approved); var rejectedCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Rejected); flowMap.TryGetValue(g.Key, out var flow); var bizType = flow?.BizType ?? g.First().BizType; return new FlowByFlowOutput { FlowId = g.Key, FlowCode = flow?.Code, FlowName = flow?.Name ?? "(已删除)", BizType = bizType, BizTypeName = !string.IsNullOrEmpty(bizType) && bizTypeMap.TryGetValue(bizType!, out var bn) ? bn : null, TotalCount = g.Count(), CompletedCount = completedCount, RunningCount = g.Count(x => x.Status == FlowInstanceStatusEnum.Running), RejectedCount = rejectedCount, PassRate = (completedCount + rejectedCount) == 0 ? 0m : Math.Round((decimal)completedCount / (completedCount + rejectedCount), 4), AvgDurationHours = durations.Count == 0 ? 0m : Math.Round(durations.Average(), 2), MaxDurationHours = durations.Count == 0 ? 0m : Math.Round(durations.Max(), 2), }; }) .OrderByDescending(x => x.TotalCount) .ToList(); } /// /// 表②:按节点维度统计(核心瓶颈分析) /// [HttpPost] [ApiDescriptionSettings(Name = "ByNode")] [DisplayName("按节点统计")] public async Task> ByNode(FlowStatisticsInput input) { var instances = await BuildInstanceQuery(input) .Select(i => new { i.Id, i.FlowId }) .ToListAsync(); if (instances.Count == 0) return new(); var instanceIds = instances.Select(i => i.Id).ToList(); var idToFlow = instances.ToDictionary(i => i.Id, i => i.FlowId); var completedNodes = await _completedNodeRep.AsQueryable() .Where(c => instanceIds.Contains(c.InstanceId)) .Select(c => new { c.InstanceId, c.NodeId, c.NodeName, c.NodeType, c.CompletedTime }) .ToListAsync(); if (completedNodes.Count == 0) return new(); var tasks = await _taskRep.AsQueryable() .Where(t => instanceIds.Contains(t.InstanceId)) .Select(t => new { t.InstanceId, t.NodeId, t.Status, t.AssigneeId, t.AssigneeName, t.CreateTime, t.ActionTime }) .ToListAsync(); var autoTimeoutLogs = await _logRep.AsQueryable() .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout) .Select(l => new { l.InstanceId, l.NodeId }) .ToListAsync(); var timeoutCountByNode = autoTimeoutLogs .Where(l => !string.IsNullOrEmpty(l.NodeId)) .GroupBy(l => (idToFlow[l.InstanceId], l.NodeId!)) .ToDictionary(g => g.Key, g => g.Count()); var flowIds = instances.Select(i => i.FlowId).Distinct().ToList(); var flows = await _flowRep.AsQueryable() .Where(f => flowIds.Contains(f.Id)) .Select(f => new { f.Id, f.Code, f.Name }) .ToListAsync(); var flowDict = flows.ToDictionary(f => f.Id); var result = new List(); foreach (var grp in completedNodes.GroupBy(c => (FlowId: idToFlow[c.InstanceId], c.NodeId))) { var flowId = grp.Key.FlowId; var nodeId = grp.Key.NodeId; var sample = grp.First(); var nodeTasks = tasks .Where(t => t.NodeId == nodeId && idToFlow[t.InstanceId] == flowId && (t.Status == FlowTaskStatusEnum.Approved || t.Status == FlowTaskStatusEnum.Rejected || t.Status == FlowTaskStatusEnum.Transferred)) .ToList(); decimal avgHours = 0, maxHours = 0; string? longest = null, shortest = null; var withAction = nodeTasks.Where(t => t.ActionTime.HasValue).ToList(); if (withAction.Count > 0) { var durations = withAction .Select(t => (decimal)(t.ActionTime!.Value - t.CreateTime).TotalHours) .ToList(); avgHours = Math.Round(durations.Average(), 2); maxHours = Math.Round(durations.Max(), 2); var longestTask = withAction .OrderByDescending(t => (t.ActionTime!.Value - t.CreateTime).TotalHours) .First(); var shortestTask = withAction .OrderBy(t => (t.ActionTime!.Value - t.CreateTime).TotalHours) .First(); longest = longestTask.AssigneeName; shortest = shortestTask.AssigneeName; } var approveCount = nodeTasks.Count(t => t.Status == FlowTaskStatusEnum.Approved); var rejectCount = nodeTasks.Count(t => t.Status == FlowTaskStatusEnum.Rejected); timeoutCountByNode.TryGetValue((flowId, nodeId), out var timeoutCount); var pending = tasks.Count(t => t.NodeId == nodeId && idToFlow[t.InstanceId] == flowId && t.Status == FlowTaskStatusEnum.Pending); flowDict.TryGetValue(flowId, out var flow); result.Add(new FlowByNodeOutput { FlowId = flowId, FlowCode = flow?.Code, FlowName = flow?.Name ?? "(已删除)", NodeId = nodeId, NodeName = sample.NodeName, NodeType = sample.NodeType, FlowCount = grp.Count(), AvgDurationHours = avgHours, MaxDurationHours = maxHours, ApproveCount = approveCount, RejectCount = rejectCount, RejectRate = (approveCount + rejectCount) == 0 ? 0m : Math.Round((decimal)rejectCount / (approveCount + rejectCount), 4), TimeoutCount = timeoutCount, PendingCount = pending, LongestApproverName = longest, ShortestApproverName = shortest, }); } return result.OrderByDescending(x => x.AvgDurationHours).ToList(); } /// /// 表③:按审批人维度统计 /// [HttpPost] [ApiDescriptionSettings(Name = "ByApprover")] [DisplayName("按审批人统计")] public async Task> ByApprover(FlowStatisticsInput input) { var instanceIds = await BuildInstanceQuery(input).Select(i => i.Id).ToListAsync(); if (instanceIds.Count == 0) return new(); var tasks = await _taskRep.AsQueryable() .Where(t => instanceIds.Contains(t.InstanceId) && t.AssigneeId > 0) .Select(t => new { t.InstanceId, t.NodeId, t.Status, t.AssigneeId, t.AssigneeName, t.CreateTime, t.ActionTime }) .ToListAsync(); if (tasks.Count == 0) return new(); var approverIds = tasks.Select(t => t.AssigneeId).Distinct().ToList(); var orgRows = await _instanceRep.Context.Queryable() .LeftJoin((u, o) => u.OrgId == o.Id) .Where(u => approverIds.Contains(u.Id)) .Select((u, o) => new { UserId = u.Id, OrgName = o.Name }) .ToListAsync(); var orgDict = orgRows.ToDictionary(x => x.UserId, x => x.OrgName); var timeoutLogs = await _logRep.AsQueryable() .Where(l => instanceIds.Contains(l.InstanceId) && l.Action == FlowLogActionEnum.AutoTimeout) .Select(l => new { l.InstanceId, l.NodeId }) .ToListAsync(); var timeoutKey = timeoutLogs .Where(l => !string.IsNullOrEmpty(l.NodeId)) .Select(l => (l.InstanceId, l.NodeId)) .ToHashSet(); return tasks.GroupBy(t => t.AssigneeId).Select(g => { var completed = g.Where(t => t.Status == FlowTaskStatusEnum.Approved || t.Status == FlowTaskStatusEnum.Rejected || t.Status == FlowTaskStatusEnum.Transferred) .ToList(); var durations = completed.Where(t => t.ActionTime.HasValue) .Select(t => (decimal)(t.ActionTime!.Value - t.CreateTime).TotalHours) .ToList(); var timeoutCount = g.Count(t => timeoutKey.Contains((t.InstanceId, t.NodeId))); return new FlowByApproverOutput { ApproverId = g.Key, ApproverName = g.First().AssigneeName ?? "", OrgName = orgDict.GetValueOrDefault(g.Key), TotalAssigned = g.Count(), CompletedCount = completed.Count, PendingCount = g.Count(t => t.Status == FlowTaskStatusEnum.Pending), AvgProcessHours = durations.Count == 0 ? 0m : Math.Round(durations.Average(), 2), TimeoutCount = timeoutCount, ApproveCount = g.Count(t => t.Status == FlowTaskStatusEnum.Approved), RejectCount = g.Count(t => t.Status == FlowTaskStatusEnum.Rejected), TransferCount = g.Count(t => t.Status == FlowTaskStatusEnum.Transferred), }; }) .OrderByDescending(x => x.TotalAssigned) .ToList(); } /// /// Top 10 最慢节点(柱图数据,仅包含 userTask) /// [HttpPost] [ApiDescriptionSettings(Name = "TopSlowNodes")] [DisplayName("最慢节点Top10")] public async Task> TopSlowNodes(FlowStatisticsInput input) { var nodes = await ByNode(input); return nodes .Where(n => n.AvgDurationHours > 0 && (n.NodeType == null || n.NodeType == "bpmn:userTask")) .OrderByDescending(n => n.AvgDurationHours) .Take(10) .Select(n => new FlowTopSlowNodeItem { FlowName = n.FlowName, NodeName = n.NodeName ?? n.NodeId, AvgDurationHours = n.AvgDurationHours, FlowCount = n.FlowCount, }) .ToList(); } // ───────────────── helpers ───────────────── private (DateTime start, DateTime end) NormalizeRange(FlowStatisticsInput input) { var end = input.EndTime ?? DateTime.Now; var start = input.StartTime ?? end.AddDays(-30); if (start > end) (start, end) = (end, start); return (start, end); } private ISugarQueryable BuildInstanceQuery(FlowStatisticsInput input) { var (start, end) = NormalizeRange(input); var q = _instanceRep.AsQueryable() .Where(i => i.StartTime >= start && i.StartTime <= end); if (input.FlowIds != null && input.FlowIds.Count > 0) q = q.Where(i => input.FlowIds.Contains(i.FlowId)); if (input.BizTypes != null && input.BizTypes.Count > 0) q = q.Where(i => input.BizTypes.Contains(i.BizType)); if (input.InitiatorOrgIds != null && input.InitiatorOrgIds.Count > 0) q = q.Where(i => input.InitiatorOrgIds.Contains(i.OrgId)); return q; } private static void AddCount(Dictionary dict, string key) { dict[key] = dict.GetValueOrDefault(key) + 1; } private class InstanceLite { public long Id { get; set; } public FlowInstanceStatusEnum Status { get; set; } public DateTime StartTime { get; set; } public DateTime? EndTime { get; set; } } }