| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- namespace Admin.NET.Plugin.ApprovalFlow.Service;
- /// <summary>
- /// 审批流程统计服务(P3-15 节点级统计)
- /// </summary>
- [ApiDescriptionSettings(ApprovalFlowConst.GroupName, Order = 50)]
- public class FlowStatisticsService : IDynamicApiController, ITransient
- {
- private readonly SqlSugarRepository<ApprovalFlowInstance> _instanceRep;
- private readonly SqlSugarRepository<ApprovalFlowTask> _taskRep;
- private readonly SqlSugarRepository<ApprovalFlowCompletedNode> _completedNodeRep;
- private readonly SqlSugarRepository<ApprovalFlowLog> _logRep;
- private readonly SqlSugarRepository<ApprovalFlow> _flowRep;
- private readonly SqlSugarRepository<ApprovalBizType> _bizTypeRep;
- public FlowStatisticsService(
- SqlSugarRepository<ApprovalFlowInstance> instanceRep,
- SqlSugarRepository<ApprovalFlowTask> taskRep,
- SqlSugarRepository<ApprovalFlowCompletedNode> completedNodeRep,
- SqlSugarRepository<ApprovalFlowLog> logRep,
- SqlSugarRepository<ApprovalFlow> flowRep,
- SqlSugarRepository<ApprovalBizType> bizTypeRep)
- {
- _instanceRep = instanceRep;
- _taskRep = taskRep;
- _completedNodeRep = completedNodeRep;
- _logRep = logRep;
- _flowRep = flowRep;
- _bizTypeRep = bizTypeRep;
- }
- /// <summary>
- /// 概览卡片 + 逐日趋势图
- /// </summary>
- [HttpPost]
- [ApiDescriptionSettings(Name = "Overview")]
- [DisplayName("审批概览")]
- public async Task<FlowOverviewOutput> 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<string, int>();
- foreach (var i in instances)
- AddCount(dailyStart, i.StartTime.Date.ToString("yyyy-MM-dd"));
- var dailyComp = new Dictionary<string, int>();
- 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<FlowTrendItem>();
- 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,
- };
- }
- /// <summary>
- /// 表①:按流程维度统计
- /// </summary>
- [HttpPost]
- [ApiDescriptionSettings(Name = "ByFlow")]
- [DisplayName("按流程统计")]
- public async Task<List<FlowByFlowOutput>> 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<string, string>();
- 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();
- }
- /// <summary>
- /// 表②:按节点维度统计(核心瓶颈分析)
- /// </summary>
- [HttpPost]
- [ApiDescriptionSettings(Name = "ByNode")]
- [DisplayName("按节点统计")]
- public async Task<List<FlowByNodeOutput>> 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<FlowByNodeOutput>();
- 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();
- }
- /// <summary>
- /// 表③:按审批人维度统计
- /// </summary>
- [HttpPost]
- [ApiDescriptionSettings(Name = "ByApprover")]
- [DisplayName("按审批人统计")]
- public async Task<List<FlowByApproverOutput>> 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<SysUser>()
- .LeftJoin<SysOrg>((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();
- }
- /// <summary>
- /// Top 10 最慢节点(柱图数据,仅包含 userTask)
- /// </summary>
- [HttpPost]
- [ApiDescriptionSettings(Name = "TopSlowNodes")]
- [DisplayName("最慢节点Top10")]
- public async Task<List<FlowTopSlowNodeItem>> 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<ApprovalFlowInstance> 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<string, int> 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; }
- }
- }
|