using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Entity; using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.SmartOps; using Admin.NET.Plugin.AiDOP.Workbench.Dto; using Admin.NET.Plugin.ApprovalFlow; using Admin.NET.Plugin.ApprovalFlow.Service; using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.Workbench; public sealed class AidopWorkbenchService : ITransient { private readonly ISqlSugarClient _db; private readonly SmartOpsTrustedScopeResolver _trustedScope; private readonly FlowTaskService _flowTasks; private readonly ILogger _logger; public AidopWorkbenchService( ISqlSugarClient db, SmartOpsTrustedScopeResolver trustedScope, FlowTaskService flowTasks, ILogger logger) { _db = db; _trustedScope = trustedScope; _flowTasks = flowTasks; _logger = logger; } public async Task GetSummaryAsync(int top) { var scope = await _trustedScope.ResolveAsync(); top = Math.Clamp(top <= 0 ? 6 : top, 1, 20); var dto = new AidopWorkbenchSummaryDto { GeneratedAt = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:sszzz"), }; dto.ImprovementActions = await SafeSection("improvement", () => LoadImprovementAsync(scope, top, dto.Counts)); dto.ApprovalTasks = await SafeSection("approval", () => LoadApprovalsAsync(top, dto.Counts)); dto.S8Tasks = await SafeSection("s8", () => LoadS8Async(scope, top, dto.Counts)); dto.UnreadNotices = await SafeSection("notice", () => LoadNoticesAsync(scope, top, dto.Counts)); dto.Pending = MergePending(dto, top); dto.Counts.PendingTotal = dto.Pending.Total; dto.Initiated = await SafeSection("initiated", () => LoadInitiatedAsync(scope, top, dto.Counts)); dto.Completed = await SafeSection("completed", () => LoadCompletedAsync(scope, top, dto.Counts)); return dto; } private async Task SafeSection(string name, Func> factory) { try { return await factory(); } catch (Exception ex) { _logger.LogWarning(ex, "工作台分区加载失败 section={Section}", name); return new AidopWorkbenchSectionDto { Available = false, ErrorCode = "SECTION_FAILED", Message = "该分区暂时不可用", }; } } private async Task LoadImprovementAsync(SmartOpsTrustedScope scope, int top, AidopWorkbenchCountsDto counts) { var today = DateTime.Today; var q = _db.Queryable() .Where(x => x.TenantId == scope.TenantId && x.OwnerUserId == scope.UserId) .Where(x => x.Status == SmartOpsImprovementActionStatus.Pending || x.Status == SmartOpsImprovementActionStatus.InProgress); var open = await q.Clone().CountAsync(); var dueSoon = await q.Clone() .Where(x => x.DueDate != null && x.DueDate.Value.Date >= today && x.DueDate.Value.Date <= today.AddDays(1)) .CountAsync(); var overdue = await q.Clone() .Where(x => x.DueDate != null && x.DueDate.Value.Date < today) .CountAsync(); counts.OverdueTotal += overdue; counts.DueToday += await q.Clone() .Where(x => x.DueDate != null && x.DueDate.Value >= today && x.DueDate.Value < today.AddDays(1)) .CountAsync(); counts.ImprovementOpen = open; counts.ImprovementDueSoon = dueSoon; counts.ImprovementOverdue = overdue; var rows = await q.Clone().OrderBy(x => x.DueDate).Take(top).ToListAsync(); var planIds = rows.Select(x => x.PlanId).Distinct().ToList(); var plans = planIds.Count == 0 ? new List() : await _db.Queryable() .Where(x => x.TenantId == scope.TenantId && planIds.Contains(x.Id)) .ToListAsync(); var planMap = plans.ToDictionary(x => x.Id, x => x); return new AidopWorkbenchSectionDto { Total = open, Items = rows.Select(x => { planMap.TryGetValue(x.PlanId, out var plan); return new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "SmartOpsAction", Title = x.Content, Status = SmartOpsImprovementActionStatus.DisplayStatus(x.Status, x.DueDate, today), DueDate = x.DueDate?.ToString("yyyy-MM-dd"), Overdue = SmartOpsImprovementActionStatus.IsOverdue(x.Status, x.DueDate, today), PlanId = x.PlanId.ToString(), PlanNo = plan?.PlanNo, Category = "改善行动", CreatedAt = x.CreateTime.ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/aidop/smart-ops/improvement-plans?id={x.PlanId}&actionId={x.Id}", }; }).ToList(), }; } private async Task LoadApprovalsAsync(int top, AidopWorkbenchCountsDto counts) { counts.ApprovalPending = await _flowTasks.MyPendingCount(); var page = await _flowTasks.MyPendingPage(new TaskPageInput { Page = 1, PageSize = top }); return new AidopWorkbenchSectionDto { Total = counts.ApprovalPending, Items = (page.Items ?? new List()).Select(t => new AidopWorkbenchItemDto { Id = t.Id.ToString(), Source = "Approval", Title = string.IsNullOrWhiteSpace(t.Title) ? t.BizType : t.Title, Status = "pending", Extra = t.BizType, Category = "审批", CreatedAt = t.CreateTime.ToString("yyyy-MM-dd HH:mm"), JumpUrl = ApprovalJumpUrl(t), }).ToList(), }; } private async Task LoadS8Async(SmartOpsTrustedScope scope, int top, AidopWorkbenchCountsDto counts) { var recIds = await _db.Queryable().ClearFilter() .Where(x => x.SysUserId == scope.UserId) .Select(x => x.Id) .ToListAsync(); if (recIds.Count == 0) { return new AidopWorkbenchSectionDto { Available = false, ErrorCode = "EMPLOYEE_UNBOUND", Message = "当前账号未绑定员工主数据,无法列出个人 S8 事项", }; } var assignedQ = _db.Queryable() .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && recIds.Contains(x.AssigneeId ?? 0)) .Where(x => x.Status != "CLOSED" && x.Status != "REJECTED" && x.Status != "RECOVERED"); var verifyQ = _db.Queryable() .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && recIds.Contains(x.VerifierId ?? 0)) .Where(x => x.Status == "PENDING_VERIFICATION"); counts.S8Assigned = await assignedQ.Clone().CountAsync(); counts.S8Verification = await verifyQ.Clone().CountAsync(); var today = DateTime.Today; counts.DueToday += await _db.Queryable() .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted) .Where(x => x.Status != "CLOSED" && x.Status != "REJECTED" && x.Status != "RECOVERED") .Where(x => recIds.Contains(x.AssigneeId ?? 0) || recIds.Contains(x.VerifierId ?? 0)) .Where(x => x.SlaDeadline != null && x.SlaDeadline >= today && x.SlaDeadline < today.AddDays(1)) .CountAsync(); counts.OverdueTotal += await _db.Queryable() .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted) .Where(x => x.Status != "CLOSED" && x.Status != "REJECTED" && x.Status != "RECOVERED") .Where(x => recIds.Contains(x.AssigneeId ?? 0) || recIds.Contains(x.VerifierId ?? 0)) .Where(x => x.TimeoutFlag || (x.SlaDeadline != null && x.SlaDeadline < DateTime.Now)) .CountAsync(); var assigned = await assignedQ.Clone().OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt).Take(top).ToListAsync(); var verify = await verifyQ.Clone().OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt).Take(top).ToListAsync(); var merged = assigned.Concat(verify) .GroupBy(x => x.Id) .Select(g => g.First()) .Take(top) .ToList(); return new AidopWorkbenchSectionDto { Total = counts.S8Assigned + counts.S8Verification, Items = merged.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "S8Exception", Title = string.IsNullOrWhiteSpace(x.Title) ? x.ExceptionCode : x.Title, Status = x.Status, DueDate = x.SlaDeadline?.ToString("yyyy-MM-dd"), Overdue = x.TimeoutFlag || (x.SlaDeadline.HasValue && x.SlaDeadline < DateTime.Now && x.Status != "CLOSED"), Extra = x.ExceptionCode, Category = "异常", CreatedAt = x.CreatedAt.ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/aidop/s8/exceptions/{x.Id}", }).ToList(), }; } private async Task LoadNoticesAsync(SmartOpsTrustedScope scope, int top, AidopWorkbenchCountsDto counts) { var q = _db.Queryable() .Includes(u => u.SysNotice) .Where(u => u.UserId == scope.UserId && u.ReadStatus == NoticeUserStatusEnum.UNREAD); counts.NoticeUnread = await q.Clone().CountAsync(); var rows = await q.Clone() .OrderByDescending(u => u.SysNotice.CreateTime) .Take(top) .ToListAsync(); return new AidopWorkbenchSectionDto { Total = counts.NoticeUnread, Items = rows.Where(x => x.SysNotice != null).Select(x => new AidopWorkbenchItemDto { Id = x.SysNotice.Id.ToString(), Source = "SysNotice", Title = x.SysNotice.Title, Status = "unread", Extra = x.SysNotice.PublicTime?.ToString("yyyy-MM-dd HH:mm"), Category = "消息", CreatedAt = x.SysNotice.CreateTime.ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/dashboard/notice?noticeId={x.SysNotice.Id}", }).ToList(), }; } private static AidopWorkbenchSectionDto MergePending(AidopWorkbenchSummaryDto dto, int top) { var sections = new[] { dto.ImprovementActions, dto.ApprovalTasks, dto.S8Tasks }; var items = sections.SelectMany(x => x.Items) .OrderByDescending(x => x.Overdue) .ThenBy(x => ParseDate(x.DueDate) ?? DateTime.MaxValue) .ThenByDescending(x => ParseDate(x.CreatedAt) ?? DateTime.MinValue) .Take(top) .ToList(); return new AidopWorkbenchSectionDto { Available = sections.Any(x => x.Available), Total = sections.Sum(x => x.Total), Items = items, Message = sections.All(x => !x.Available) ? "待办数据暂时不可用" : null, }; } private async Task LoadInitiatedAsync( SmartOpsTrustedScope scope, int top, AidopWorkbenchCountsDto counts) { var actionPlanIds = await _db.Queryable() .Where(x => x.TenantId == scope.TenantId && x.CreateUserId == scope.UserId) .Select(x => x.PlanId) .Distinct() .ToListAsync(); var planCount = actionPlanIds.Count; var plans = actionPlanIds.Count == 0 ? new List() : await _db.Queryable() .Where(x => x.TenantId == scope.TenantId && actionPlanIds.Contains(x.Id)) .OrderByDescending(x => x.CreateTime) .Take(top) .ToListAsync(); var recIds = await ResolveEmployeeIds(scope.UserId); var reportedQ = _db.Queryable() .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted && recIds.Contains(x.ReporterId ?? 0)); var reportedCount = recIds.Count == 0 ? 0 : await reportedQ.Clone().CountAsync(); var reported = recIds.Count == 0 ? new List() : await reportedQ.Clone().OrderByDescending(x => x.CreatedAt).Take(top).ToListAsync(); var flowQ = _db.Queryable() .Where(x => x.InitiatorId == scope.UserId) .Where(x => x.BizType != "SMART_OPS_IMPROVEMENT" && !x.BizType.StartsWith("EXCEPTION_")); var flowCount = await flowQ.Clone().CountAsync(); var flows = await flowQ.Clone().OrderByDescending(x => x.StartTime).Take(top).ToListAsync(); var items = plans.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "ImprovementPlan", Category = "改善计划", Title = string.IsNullOrWhiteSpace(x.ProblemName) ? x.PlanNo : x.ProblemName, Status = x.Status, DueDate = x.DueDate?.ToString("yyyy-MM-dd"), PlanId = x.Id.ToString(), PlanNo = x.PlanNo, CreatedAt = x.CreateTime.ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/aidop/smart-ops/improvement-plans?id={x.Id}", }) .Concat(reported.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "S8Reported", Category = "异常提报", Title = string.IsNullOrWhiteSpace(x.Title) ? x.ExceptionCode : x.Title, Status = x.Status, DueDate = x.SlaDeadline?.ToString("yyyy-MM-dd"), Extra = x.ExceptionCode, CreatedAt = x.CreatedAt.ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/aidop/s8/exceptions/{x.Id}", })) .Concat(flows.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "ApprovalInitiated", Category = "审批发起", Title = x.Title, Status = x.Status.ToString(), Extra = x.BizType, CreatedAt = x.StartTime.ToString("yyyy-MM-dd HH:mm"), CompletedAt = x.EndTime?.ToString("yyyy-MM-dd HH:mm"), JumpUrl = "/aidop/flowManage/approvalFlowCenter", })) .OrderByDescending(x => ParseDate(x.CreatedAt) ?? DateTime.MinValue) .Take(top) .ToList(); counts.InitiatedTotal = planCount + reportedCount + flowCount; return new AidopWorkbenchSectionDto { Total = counts.InitiatedTotal, Items = items }; } private async Task LoadCompletedAsync( SmartOpsTrustedScope scope, int top, AidopWorkbenchCountsDto counts) { var actionsQ = _db.Queryable() .Where(x => x.TenantId == scope.TenantId && x.OwnerUserId == scope.UserId) .Where(x => x.Status == SmartOpsImprovementActionStatus.Completed); var actionCount = await actionsQ.Clone().CountAsync(); var actions = await actionsQ.Clone() .OrderByDescending(x => x.CompletedAt ?? x.UpdateTime) .Take(top) .ToListAsync(); var recIds = await ResolveEmployeeIds(scope.UserId); var closedQ = _db.Queryable() .Where(x => x.TenantId == scope.TenantId && !x.IsDeleted) .Where(x => x.Status == "CLOSED" || x.Status == "REJECTED" || x.Status == "RECOVERED") .Where(x => recIds.Contains(x.AssigneeId ?? 0) || recIds.Contains(x.VerifierId ?? 0)); var closedCount = recIds.Count == 0 ? 0 : await closedQ.Clone().CountAsync(); var closed = recIds.Count == 0 ? new List() : await closedQ.Clone().OrderByDescending(x => x.ClosedAt ?? x.UpdatedAt).Take(top).ToListAsync(); var donePage = await _flowTasks.MyDonePage(new TaskPageInput { Page = 1, PageSize = top }); var doneCount = donePage.Total; var done = donePage.Items ?? new List(); var items = actions.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "SmartOpsActionCompleted", Category = "改善行动", Title = x.Content, Status = x.Status, DueDate = x.DueDate?.ToString("yyyy-MM-dd"), PlanId = x.PlanId.ToString(), CompletedAt = (x.CompletedAt ?? x.UpdateTime).ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/aidop/smart-ops/improvement-plans?id={x.PlanId}&actionId={x.Id}", }) .Concat(closed.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "S8Completed", Category = "异常", Title = string.IsNullOrWhiteSpace(x.Title) ? x.ExceptionCode : x.Title, Status = x.Status, Extra = x.ExceptionCode, CompletedAt = (x.ClosedAt ?? x.UpdatedAt ?? x.CreatedAt).ToString("yyyy-MM-dd HH:mm"), JumpUrl = $"/aidop/s8/exceptions/{x.Id}", })) .Concat(done.Select(x => new AidopWorkbenchItemDto { Id = x.Id.ToString(), Source = "ApprovalCompleted", Category = "审批", Title = string.IsNullOrWhiteSpace(x.Title) ? x.BizType : x.Title, Status = x.Status.ToString(), Extra = x.BizType, CompletedAt = x.ActionTime?.ToString("yyyy-MM-dd HH:mm"), JumpUrl = ApprovalJumpUrl(x), })) .OrderByDescending(x => ParseDate(x.CompletedAt) ?? DateTime.MinValue) .Take(top) .ToList(); counts.CompletedTotal = actionCount + closedCount + doneCount; return new AidopWorkbenchSectionDto { Total = counts.CompletedTotal, Items = items }; } private async Task> ResolveEmployeeIds(long userId) { return await _db.Queryable().ClearFilter() .Where(x => x.SysUserId == userId) .Select(x => x.Id) .ToListAsync(); } private static DateTime? ParseDate(string? value) => DateTime.TryParse(value, out var parsed) ? parsed : null; private static string ApprovalJumpUrl(TaskPageOutput t) { var bizType = t.BizType ?? ""; if (bizType.StartsWith("EXCEPTION", StringComparison.OrdinalIgnoreCase) && t.BizId > 0) return $"/aidop/s8/exceptions/{t.BizId}"; if (string.Equals(bizType, "SMART_OPS_IMPROVEMENT", StringComparison.OrdinalIgnoreCase) && t.BizId > 0) return $"/aidop/smart-ops/improvement-plans?id={t.BizId}"; return $"/aidop/flowManage/approvalFlowCenter?taskId={t.Id}"; } }