using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.Reports; /// Готовый файл выгрузки и его имя. public sealed record RouteExportFile(byte[] Content, string FileName); /// /// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов /// заявок, превращает их в понятные человеку строки (роль, исполнитель, /// организация, условие, комментарий) и отдаёт на вёрстку в /// . /// /// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага, /// ), у каждой своя нумерация. /// public sealed class RouteExportService( ApprovalRouteDbContext db, ITnsuPortalClient portalClient, IOptions portalOptions) { private const int MaxTypesInFileName = 7; private const int MaxTypeNameLength = 20; private const string Empty = "—"; private const string Always = "Всегда"; private const string AnyCompany = "любая компания"; private const string AnyDepartment = "любой отдел"; private const string ByRequest = "Определяется по заявке"; /// /// Технические имена колонок списка, которые в таблице показываем по-человечески /// (CompaniesJson — организации заявки). /// private static readonly Dictionary ConditionFieldAliases = new(StringComparer.Ordinal) { ["CompaniesJson"] = "организация" }; private static readonly Dictionary ProjectRoleLabels = new() { [ProjectRole.ProjectManager] = "Руководитель проекта", [ProjectRole.Planner] = "Планировщик", [ProjectRole.ManagerHSE] = "Менеджер HSE", [ProjectRole.Security] = "Безопасность", [ProjectRole.Keeper] = "Заведующий склада" }; private static readonly Dictionary HeadTypeLabels = new() { [HeadType.Chief] = "Непосредственный руководитель", [HeadType.DirectionHead] = "Руководитель направления", [HeadType.DirectorTansu] = "Директор Tansu", [HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu", [HeadType.DepartmentHead] = "Руководитель указанного отдела", [HeadType.CompanyDirectors] = "Директора компаний инициатора" }; private static readonly Dictionary OperatorSymbols = new() { [ComparisonOperator.GreaterThan] = ">", [ComparisonOperator.LessThan] = "<", [ComparisonOperator.Equal] = "=", [ComparisonOperator.NotEqual] = "≠", [ComparisonOperator.Contains] = "содержит", [ComparisonOperator.NotContains] = "не содержит" }; public async Task> BuildAsync(IReadOnlyList? requestTypeIds, CancellationToken cancellationToken) { if (requestTypeIds is not { Count: > 0 }) return Result.Fail("Не выбрано ни одного типа заявки."); var types = await db.RequestTypes.AsNoTracking() .Where(x => requestTypeIds.Contains(x.Id)) .OrderBy(x => x.Name) .ToListAsync(cancellationToken); if (types.Count == 0) return Result.Fail("Выбранные типы заявок не найдены."); var routes = await db.TemplateRoutes.AsNoTracking() .Where(x => requestTypeIds.Contains(x.RequestTypeId)) .Include(x => x.Steps).ThenInclude(s => s.Conditions) // Две вложенные коллекции (шаги и их условия) одним запросом дали бы декартово // произведение строк — режем на отдельные запросы. .AsSplitQuery() .OrderBy(x => x.Id) .ToListAsync(cancellationToken); var directory = await LoadDirectoryAsync(cancellationToken); var sheets = types .Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory)) .ToList(); return Result.Ok(new RouteExportFile( RouteExportWorkbook.Build(sheets), FileName(types.Select(t => t.Name).ToList(), DateOnly.FromDateTime(DateTime.Now)))); } /// /// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках /// сокращённые названия типов (не больше , остальные /// сворачиваются в «и др.»), в конце — дата выгрузки. /// public static string FileName(IReadOnlyList typeNames, DateOnly date) { var shown = typeNames.Take(MaxTypesInFileName).Select(Abbreviate).Where(n => n.Length > 0).ToList(); var list = string.Join(", ", shown) + (typeNames.Count > MaxTypesInFileName ? " и др." : string.Empty); var name = $"Маршруты согласования ({list}) {date:dd.MM.yyyy}.xlsx"; return string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? ' ' : c)); } // «Заявка на подбор персонала» → «подбор персонала»: служебное начало названия // ничего не говорит, а длинное название раздуло бы имя файла. private static string Abbreviate(string typeName) { var name = typeName.Trim(); foreach (var prefix in new[] { "Заявка на ", "Заявка о ", "Заявка " }) { if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { name = name[prefix.Length..]; break; } } if (name.Length <= MaxTypeNameLength) return name; var cut = name[..MaxTypeNameLength]; var lastSpace = cut.LastIndexOf(' '); return (lastSpace > MaxTypeNameLength / 2 ? cut[..lastSpace] : cut).TrimEnd() + "…"; } private async Task LoadDirectoryAsync(CancellationToken cancellationToken) { // Справочники кешируются клиентом портала (см. CachingTnsuPortalClient). var employees = await portalClient.GetAllEmployeesAsync(cancellationToken); var companies = await portalClient.GetCompaniesAsync(cancellationToken); var departments = await portalClient.GetDepartmentsAsync(cancellationToken); return new PortalDirectory( employees, companies.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First().Name), departments.GroupBy(d => d.Id).ToDictionary(g => g.Key, g => g.First().Name)); } private RouteExportSheet BuildSheet(RequestType type, IReadOnlyList routes, PortalDirectory directory) { var sections = routes .Select(route => { return new RouteExportSection( routes.Count < 2 ? null : RouteTitle(route, directory), BuildGroups(route.Steps, directory)); }) .ToList(); return new RouteExportSheet(type.Name, sections); } private static string RouteTitle(TemplateRoute route, PortalDirectory directory) { var company = route.CompanyId is { } c ? directory.CompanyName(c) : AnyCompany; var department = route.DepartmentId is { } d ? directory.DepartmentName(d) : AnyDepartment; return $"Маршрут: {company} · {department}{(route.ByProject ? " · по проекту" : string.Empty)}"; } private List BuildGroups(IEnumerable steps, PortalDirectory directory) { var ordered = steps.OrderBy(s => s.OrderNo).ToList(); var approvers = ordered.Where(s => s.Status != RouteStepStatus.Execution).ToList(); var executors = ordered.Where(s => s.Status == RouteStepStatus.Execution).ToList(); return new[] { new RouteExportGroup("Согласующие", BuildRows(approvers, directory)), new RouteExportGroup("Исполнители", BuildRows(executors, directory)) } .Where(g => g.Rows.Count > 0) .ToList(); } // Параллельные шаги (равный OrderNo) получают один и тот же номер. private List BuildRows(IReadOnlyList steps, PortalDirectory directory) { var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList(); return steps .Select(step => { var (role, executor, organization) = DescribeRoleAndExecutor(step, directory); return new RouteExportRow( orderNos.IndexOf(step.OrderNo) + 1, role, executor, organization, DescribeCondition(step), DescribeComment(step)); }) .ToList(); } // «Роль» — должность или тип руководителя, «исполнитель» — конкретный человек, // если он известен уже при настройке маршрута. Директор Tansu и Управляющий // директор Tansu — тоже конкретные люди (их email лежит в настройках). private (string Role, string Executor, string Organization) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory) { if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) { var employee = directory.FindByEmail(step.ExecutorEmail); return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail, directory.CompaniesOf(step.ExecutorEmail)); } if (!string.IsNullOrWhiteSpace(step.Position)) { var holder = directory.FindByPosition(step.Position); return (step.Position, holder?.Fio ?? Empty, directory.CompaniesOf(holder?.Email)); } if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) return (ProjectRoleLabels[projectRole], ByRequest, Empty); if (step.HeadType is { } headType && headType != HeadType.None) { var fixedEmail = headType switch { HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail, _ => null }; if (fixedEmail is not null) // Нет в справочнике — лучше показать email, чем пустую ячейку. return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail)); return (HeadTypeLabels[headType], ByRequest, Empty); } return (Empty, Empty, Empty); } private static string DescribeCondition(TemplateRouteStep step) { if (step.Conditions.Count == 0) return Always; var joiner = step.ConditionsLogic == ConditionsLogic.Or ? " ИЛИ " : " И "; return string.Join(joiner, step.Conditions.OrderBy(c => c.OrderNo).Select(c => { var field = ConditionFieldAliases.GetValueOrDefault(c.FieldName, c.FieldName); var symbol = OperatorSymbols.GetValueOrDefault(c.Operation, string.Empty); return $"{field} {symbol} {c.Value}"; })); } // Галочка «можно переназначить» — шаг может передать другому: согласующий // назначает согласующего, исполнитель — исполнителя. private static string DescribeComment(TemplateRouteStep step) => !step.AllowReassignToOtherApprovers ? string.Empty : step.Status == RouteStepStatus.Execution ? "может назначить исполнителя" : "может назначить согласующего"; private static string EmptyIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? Empty : value; private sealed class PortalDirectory( IReadOnlyList employees, IReadOnlyDictionary companies, IReadOnlyDictionary departments) { // Справочник даёт строку на каждую компанию сотрудника — берём первую с // заполненной должностью, остальные для ФИО/должности ничего не добавляют. public EmployeeContactInfo? FindByEmail(string email) => employees.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(e => !string.IsNullOrWhiteSpace(e.Position)) .FirstOrDefault(); public EmployeeContactInfo? FindByPosition(string position) => employees.FirstOrDefault(e => string.Equals(e.Position?.Trim(), position.Trim(), StringComparison.OrdinalIgnoreCase)); // Организация — компания СОТРУДНИКА, а не маршрута. Справочник даёт строку на // каждую компанию, где он числится (совместители), поэтому выводим все. public string CompaniesOf(string? email) { if (string.IsNullOrWhiteSpace(email)) return Empty; var names = employees .Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(e.Company)) .Select(e => e.Company) .Distinct() .ToList(); return names.Count == 0 ? Empty : string.Join(", ", names); } // Пока справочник не отдал имя (или запись удалена) — усечённый Guid, как в админке. public string CompanyName(Guid id) => companies.GetValueOrDefault(id) ?? ShortGuid(id); public string DepartmentName(Guid id) => departments.GetValueOrDefault(id) ?? ShortGuid(id); private static string ShortGuid(Guid id) => id.ToString()[..8]; } }