diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index f2992f6..5fb61b6 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs @@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize(Policy = AuthPolicies.Any)] [Route("api/reports")] -public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase +public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase { private const string XlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; @@ -54,19 +54,19 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll /// /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). - /// POST, потому что строки собирает админка — у неё уже есть справочники - /// сотрудников и подписи шагов (см. RouteExportWorkbook). + /// POST — потому что в теле список id типов (см. RouteExportService). /// [HttpPost("routes")] [Produces(XlsxContentType)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public IActionResult Routes([FromBody] RouteExportRequest request) + public async Task Routes([FromBody] RouteExportRequest request, CancellationToken ct = default) { - if (request.Sheets is not { Count: > 0 }) - return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } }); + var result = await routeExport.BuildAsync(request.RequestTypeIds, ct); + if (result.IsFailed) + return BadRequest(new { errors = result.Errors.Select(e => e.Message) }); - var file = RouteExportWorkbook.Build(request); + var file = result.Value; var disposition = new ContentDispositionHeaderValue("attachment"); disposition.SetHttpFileName(RouteExportWorkbook.FileName); diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 4ae58c7..64527fc 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs new file mode 100644 index 0000000..22d4569 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs @@ -0,0 +1,249 @@ +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 class RouteExportService( + ApprovalRouteDbContext db, + ITnsuPortalClient portalClient, + IOptions portalOptions) +{ + private const string Empty = "—"; + private const string Always = "Всегда"; + private const string AnyOrganization = "Любая"; + 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) + .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(RouteExportWorkbook.Build(sheets)); + } + + 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 => + { + var organization = route.CompanyId is { } companyId + ? directory.CompanyName(companyId) + : AnyOrganization; + return new RouteExportSection( + routes.Count < 2 ? null : RouteTitle(route, directory), + BuildGroups(route.Steps, organization, 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, string organization, 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, organization, directory)), + new RouteExportGroup("Исполнители", BuildRows(executors, organization, directory)) + } + .Where(g => g.Rows.Count > 0) + .ToList(); + } + + // Параллельные шаги (равный OrderNo) получают один и тот же номер. + private List BuildRows(IReadOnlyList steps, string organization, PortalDirectory directory) + { + var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList(); + + return steps + .Select(step => + { + var (role, executor) = 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) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory) + { + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + { + var employee = directory.FindByEmail(step.ExecutorEmail); + return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail); + } + + if (!string.IsNullOrWhiteSpace(step.Position)) + return (step.Position, directory.FindByPosition(step.Position)?.Fio ?? Empty); + + if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) + return (ProjectRoleLabels[projectRole], ByRequest); + + 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); + + return (HeadTypeLabels[headType], ByRequest); + } + + return (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)); + + // Пока справочник не отдал имя (или запись удалена) — усечённый 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]; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs index c7cfc4b..eb1fddd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -4,28 +4,37 @@ using NPOI.XSSF.UserModel; namespace Tnsu.ApprovalRoute.Application.Reports; -/// Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка). -public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition); +/// Строка выгрузки маршрута: шаг в понятном человеку виде. +public sealed record RouteExportRow( + int Number, + string Role, + string Executor, + string Organization, + string Condition, + string Comment); + +/// Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация). +public sealed record RouteExportGroup(string Caption, IReadOnlyList Rows); /// /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// (по компании/отделу) и их нужно различать на одном листе; у единственного /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// -public sealed record RouteExportSection(string? Title, IReadOnlyList Rows); +public sealed record RouteExportSection(string? Title, IReadOnlyList Groups); /// Лист выгрузки — один тип заявки. public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); -public sealed record RouteExportRequest(IReadOnlyList Sheets); +/// Тело запроса POST api/reports/routes — какие типы заявок выгрузить. +public sealed record RouteExportRequest(IReadOnlyList RequestTypeIds); /// /// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: /// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». /// -/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: -/// там уже есть справочники сотрудников и логика описания шага, дублировать их -/// на бэкенде незачем. Здесь только вёрстка файла. +/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает +/// . /// public static class RouteExportWorkbook { @@ -37,12 +46,14 @@ public static class RouteExportWorkbook (" ", 6.0), ("Роль ", 41.1), ("Исполнитель ", 42.2), + ("Организация ", 28.0), ("Условие ", 66.2), + ("Комментарий ", 36.0), ]; public const string FileName = "Маршруты согласования.xlsx"; - public static byte[] Build(RouteExportRequest request) + public static byte[] Build(IReadOnlyList sheets) { var workbook = new XSSFWorkbook(); @@ -51,7 +62,7 @@ public static class RouteExportWorkbook var titleStyle = CreateStyle(workbook, bold: true, border: false); var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var sheetData in request.Sheets) + foreach (var sheetData in sheets) { var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); for (var i = 0; i < Columns.Length; i++) @@ -60,8 +71,6 @@ public static class RouteExportWorkbook var rowIndex = 0; foreach (var section in sheetData.Sections) { - if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами - if (!string.IsNullOrWhiteSpace(section.Title)) { var titleRow = sheet.CreateRow(rowIndex); @@ -72,23 +81,37 @@ public static class RouteExportWorkbook rowIndex++; } - var header = sheet.CreateRow(rowIndex++); - for (var i = 0; i < Columns.Length; i++) + foreach (var group in section.Groups) { - var cell = header.CreateCell(i); - cell.SetCellValue(Columns[i].Title); - cell.CellStyle = headerStyle; - } + var captionRow = sheet.CreateRow(rowIndex); + var captionCell = captionRow.CreateCell(0); + captionCell.SetCellValue(group.Caption); + captionCell.CellStyle = titleStyle; + sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1)); + rowIndex++; - foreach (var row in section.Rows) - { - var excelRow = sheet.CreateRow(rowIndex++); - var numberCell = excelRow.CreateCell(0); - numberCell.SetCellValue(row.Number); - numberCell.CellStyle = cellStyle; - WriteText(excelRow, 1, row.Role, cellStyle); - WriteText(excelRow, 2, row.Executor, cellStyle); - WriteText(excelRow, 3, row.Condition, cellStyle); + var header = sheet.CreateRow(rowIndex++); + for (var i = 0; i < Columns.Length; i++) + { + var cell = header.CreateCell(i); + cell.SetCellValue(Columns[i].Title); + cell.CellStyle = headerStyle; + } + + foreach (var row in group.Rows) + { + var excelRow = sheet.CreateRow(rowIndex++); + var numberCell = excelRow.CreateCell(0); + numberCell.SetCellValue(row.Number); + numberCell.CellStyle = cellStyle; + WriteText(excelRow, 1, row.Role, cellStyle); + WriteText(excelRow, 2, row.Executor, cellStyle); + WriteText(excelRow, 3, row.Organization, cellStyle); + WriteText(excelRow, 4, row.Condition, cellStyle); + WriteText(excelRow, 5, row.Comment, cellStyle); + } + + rowIndex++; // пустая строка между таблицами } } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 4b931e2..4947094 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC public Task> GetAllEmployeesAsync(CancellationToken cancellationToken) => GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken)); + public Task> GetCompaniesAsync(CancellationToken cancellationToken) => + GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken)); + + public Task> GetDepartmentsAsync(CancellationToken cancellationToken) => + GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken)); + public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => GetOrAddAsync( $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 4a56e4e..8fb9a9c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient( return employees .Where(e => !string.IsNullOrWhiteSpace(e.Fio)) - .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty)) + .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty, e.Position)) + .ToList(); + } + + // /Dictionary/* отдаёт «голый» массив с ключами в PascalCase (Id/Name) — + // PropertyNameCaseInsensitive в JsonOptions это покрывает. + public async Task> GetCompaniesAsync(CancellationToken cancellationToken) + { + var companies = await GetAsync>("/Dictionary/Companies", cancellationToken); + return (companies ?? []) + .Where(c => c.Id is not null && !string.IsNullOrWhiteSpace(c.Name)) + .Select(c => new CompanyInfo(c.Id!.Value, c.Name!)) + .ToList(); + } + + public async Task> GetDepartmentsAsync(CancellationToken cancellationToken) + { + var departments = await GetAsync>("/Dictionary/Departments", cancellationToken); + return (departments ?? []) + .Where(d => d.Id is not null && !string.IsNullOrWhiteSpace(d.Name)) + .Select(d => new DepartmentInfo(d.Id!.Value, d.Name!)) .ToList(); } @@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient( [property: JsonPropertyName("fio")] string? Fio, [property: JsonPropertyName("id")] Guid? Id = null); + private sealed record DictionaryItemResponse( + [property: JsonPropertyName("id")] Guid? Id, + [property: JsonPropertyName("name")] string? Name); + private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 4da2118..0222c44 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -20,6 +20,12 @@ public interface ITnsuPortalClient /// Task> GetAllEmployeesAsync(CancellationToken cancellationToken); + /// Справочник компаний — /Dictionary/Companies. + Task> GetCompaniesAsync(CancellationToken cancellationToken); + + /// Справочник отделов — /Dictionary/Departments. + Task> GetDepartmentsAsync(CancellationToken cancellationToken); + Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); /// diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 7940891..ee37eb2 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, /// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно /// по — он один и тот же в обоих ответах. /// -public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company); +public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company, string? Position = null); + +/// Компания из справочника /Dictionary/Companies. +public sealed record CompanyInfo(Guid Id, string Name); + +/// Отдел из справочника /Dictionary/Departments. +public sealed record DepartmentInfo(Guid Id, string Name); diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index b568590..c2bab51 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -186,7 +186,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint route={route.resolve} /> )} - {route.tab === 'routesExport' && } + {route.tab === 'routesExport' && } {route.tab === 'manageRequests' && ( )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx index 5407a06..4ed796a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -1,19 +1,13 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; -import { PortalDictionaryClient } from '../../services/portalDictionary'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; -import { usePortalEmployees } from '../../hooks/usePortalEmployees'; -import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { reportError } from '../../utils/errors'; -import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport'; import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; export interface IRoutesExportScreenProps { api: ApprovalRouteApi; - /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ - dictionary?: PortalDictionaryClient; } // Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно @@ -31,10 +25,8 @@ function download(blob: Blob, fileName: string): void { URL.revokeObjectURL(url); } -export const RoutesExportScreen: React.FC = ({ api, dictionary }) => { +export const RoutesExportScreen: React.FC = ({ api }) => { const requestTypes = useRequestTypes(api); - const employees = usePortalEmployees(dictionary); - const portalDictionaries = usePortalDictionaries(dictionary); const [storedSelected, setSelected] = useLocalStorageState(SELECTED_STORAGE_KEY, []); const [isBusy, setIsBusy] = React.useState(false); @@ -51,27 +43,12 @@ export const RoutesExportScreen: React.FC = ({ api, di const runExport = (): void => { setError(undefined); setIsBusy(true); - // Порядок листов — как в списке типов, а не в порядке кликов. - const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1); - - Promise.all( - types.map(async rt => { - const routes = await api.templateRoutes.list(rt.id); - const items: RouteWithSteps[] = await Promise.all( - routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) })) - ); - return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments); - }) - ) - .then(sheets => api.reports.routes({ sheets })) + api.reports.routes(selected) .then(blob => download(blob, texts.routesExport.fileName)) .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) .then(() => setIsBusy(false), () => setIsBusy(false)); }; - // Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться. - const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading; - return ( @@ -83,7 +60,7 @@ export const RoutesExportScreen: React.FC = ({ api, di {isBusy ? texts.routesExport.exporting : texts.routesExport.export} @@ -92,7 +69,6 @@ export const RoutesExportScreen: React.FC = ({ api, di /> {requestTypes.error && {requestTypes.error}} - {employees.error && {employees.error}} {error && {error}} diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 7cd0345..4c8e0dc 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -9,4 +9,3 @@ export * from './templateRouteSteps'; export * from './resolve'; export * from './requestManage'; export * from './manageByUser'; -export * from './routeExport'; diff --git a/frontend/src/webparts/approvalAdmin/models/routeExport.ts b/frontend/src/webparts/approvalAdmin/models/routeExport.ts deleted file mode 100644 index 1a41943..0000000 --- a/frontend/src/webparts/approvalAdmin/models/routeExport.ts +++ /dev/null @@ -1,24 +0,0 @@ -// DTO эндпоинта POST api/reports/routes (Application/Reports/RouteExportWorkbook.cs). - -export interface RouteExportRow { - number: number; - role: string; - executor: string; - condition: string; -} - -export interface RouteExportSection { - /** Заголовок маршрута — только когда у типа заявки их несколько. */ - title?: string; - rows: RouteExportRow[]; -} - -/** Лист файла — один тип заявки. */ -export interface RouteExportSheet { - name: string; - sections: RouteExportSection[]; -} - -export interface RouteExportRequest { - sheets: RouteExportSheet[]; -} diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 8df7528..2448b37 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -13,7 +13,6 @@ import { PatchTemplateRouteInput, PatchTemplateRouteStepInput, RequestListPartition, - RouteExportRequest, RequestType, RequestTypeAccess, RequestTypeListSeries, @@ -176,7 +175,7 @@ export class ApprovalRouteApi { // Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON. public readonly reports = { - routes: (body: RouteExportRequest): Promise => - this.http.postForBlob('/api/reports/routes', body) + routes: (requestTypeIds: number[]): Promise => + this.http.postForBlob('/api/reports/routes', { requestTypeIds }) }; } diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index 59c4c0e..d1bec63 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -211,16 +211,12 @@ export const texts = { breadcrumb: 'Выгрузка маршрутов', subtitle: 'Маршруты согласования в Excel', title: 'Выгрузка маршрутов', - hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).', + hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).', selectAll: 'Выбрать все', export: 'Выгрузить', exporting: 'Формирование файла...', emptyTypes: 'Типов заявок пока нет.', selected: (count: number): string => `Выбрано: ${count}`, - routeTitle: (company: string, department: string, byProject: boolean): string => - `Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`, - always: 'Всегда', - executorByRequest: 'Определяется по заявке', fileName: 'Маршруты согласования.xlsx', failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}` }, diff --git a/frontend/src/webparts/approvalAdmin/utils/routeExport.ts b/frontend/src/webparts/approvalAdmin/utils/routeExport.ts deleted file mode 100644 index 7974f1b..0000000 --- a/frontend/src/webparts/approvalAdmin/utils/routeExport.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Company, Department, Employee, HeadType, ProjectRole, RequestType, RouteExportRow, RouteExportSheet, TemplateRoute, TemplateRouteStep } from '../models'; -import { headTypeLabels, projectRoleLabels, texts } from '../texts'; -import { describeStepCondition } from './stepExecutor'; -import { findEmployeeByEmail } from './employee'; -import { shortGuid } from './guid'; - -export interface RouteWithSteps { - route: TemplateRoute; - steps: TemplateRouteStep[]; -} - -/** - * Роль и исполнитель шага для выгрузки — как в ручной таблице HR: «роль» это - * должность/тип руководителя, «исполнитель» — конкретный человек, если он - * известен уже на этапе настройки маршрута. - */ -function describeRoleAndExecutor(step: TemplateRouteStep, employees: Employee[]): { role: string; executor: string } { - if (step.executorEmail) { - const employee = findEmployeeByEmail(employees, step.executorEmail); - return { role: employee?.position || texts.common.empty, executor: employee?.fio ?? step.executorEmail }; - } - if (step.position) { - const wanted = step.position.trim().toLowerCase(); - const holder = employees.find(e => e.position.trim().toLowerCase() === wanted); - return { role: step.position, executor: holder?.fio ?? texts.common.empty }; - } - if (step.projectRole && step.projectRole !== ProjectRole.None) { - return { role: projectRoleLabels[step.projectRole], executor: texts.routesExport.executorByRequest }; - } - if (step.headType && step.headType !== HeadType.None) { - return { role: headTypeLabels[step.headType], executor: texts.routesExport.executorByRequest }; - } - return { role: texts.common.empty, executor: texts.common.empty }; -} - -/** Строки листа: параллельные шаги (равный orderNo) получают один и тот же номер. */ -export function buildRouteRows(steps: TemplateRouteStep[], employees: Employee[]): RouteExportRow[] { - const orderNos = Array.from(new Set(steps.map(s => s.orderNo))).sort((a, b) => a - b); - return [...steps] - .sort((a, b) => a.orderNo - b.orderNo) - .map(step => ({ - number: orderNos.indexOf(step.orderNo) + 1, - ...describeRoleAndExecutor(step, employees), - condition: describeStepCondition(step) ?? texts.routesExport.always - })); -} - -/** Лист на тип заявки. Заголовки маршрутов нужны, только когда их у типа несколько. */ -export function buildRouteSheet( - requestType: RequestType, - items: RouteWithSteps[], - employees: Employee[], - companies: Company[], - departments: Department[] -): RouteExportSheet { - const title = (route: TemplateRoute): string | undefined => { - if (items.length < 2) return undefined; - const company = route.companyId - ? companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId) - : texts.routeSteps.anyCompany; - const department = route.departmentId - ? departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId) - : texts.routeSteps.anyDepartment; - return texts.routesExport.routeTitle(company, department, route.byProject); - }; - - return { - name: requestType.name, - sections: items.map(i => ({ title: title(i.route), rows: buildRouteRows(i.steps, employees) })) - }; -}