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; }
}
}