10 Commits

Author SHA1 Message Date
gorbenko.pavel 32fe3904e3 Выгрузка маршрутов: загрузка шагов и условий раздельными запросами (AsSplitQuery)
Tests / frontend-test (pull_request) Successful in 1m31s
Tests / backend-test (pull_request) Successful in 1m55s
Убирает предупреждение EF про MultipleCollectionIncludeWarning.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:48:28 +05:00
gorbenko.pavel bacf18c608 Выгрузка маршрутов: в имени файла сокращённые типы заявок (до 7) и дата
Имя формирует бэкенд и отдаёт в Content-Disposition (он добавлен в
экспонируемые заголовки CORS), фронт сохраняет файл под этим именем.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:40:19 +05:00
gorbenko.pavel dce71ab672 Выгрузка маршрутов: комментарий «может назначить согласующего/исполнителя»
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:38:25 +05:00
gorbenko.pavel 4d3453da34 Выгрузка маршрутов: исполнители идут сразу под согласующими без шапки и пустой строки
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:35:20 +05:00
gorbenko.pavel f68c69163f RouteExportRequest: RequestTypeIds необязателен на уровне привязки, ошибку отдаёт сервис
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:28:49 +05:00
gorbenko.pavel 7c64f3ccf4 Выгрузка маршрутов: колонка «Организация» — компания сотрудника, а не маршрута
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:26:09 +05:00
gorbenko.pavel 6433299c62 Выгрузка маршрутов: сборка таблицы перенесена на бэкенд, добавлены организация, комментарий и деление на согласующих/исполнителей
Бэкенд сам читает маршруты, шаги и справочники портала (сотрудники с должностями,
компании, отделы), фронт шлёт только id типов заявок и скачивает файл.
Колонки: №, Роль, Исполнитель, Организация, Условие, Комментарий.
Согласующие и исполнители — отдельными таблицами. Условие по CompaniesJson
показывается как «организация». Директор Tansu и Управляющий директор Tansu
выводятся с ФИО. Комментарий «назначает согласующего/исполнителя» — при галочке
переназначения.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:24:18 +05:00
gorbenko.pavel 43b3c77ac1 Сохранять выбранные галочки на вкладке «Выгрузка маршрутов» в localStorage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:50:04 +05:00
gorbenko.pavel 75214f5797 переделал на нечеткий поиск 2026-10-13 09:38:13 +05:00
gorbenko.pavel 0b06e2b958 Свернуть appsettings.*.json под appsettings.json в Solution Explorer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:29:07 +05:00
18 changed files with 472 additions and 178 deletions
@@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController] [ApiController]
[Authorize(Policy = AuthPolicies.Any)] [Authorize(Policy = AuthPolicies.Any)]
[Route("api/reports")] [Route("api/reports")]
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
{ {
private const string XlsxContentType = private const string XlsxContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
@@ -54,24 +54,24 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
/// <summary> /// <summary>
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
/// POST, потому что строки собирает админка — у неё уже есть справочники /// POST — потому что в теле список id типов (см. RouteExportService).
/// сотрудников и подписи шагов (см. RouteExportWorkbook).
/// </summary> /// </summary>
[HttpPost("routes")] [HttpPost("routes")]
[Produces(XlsxContentType)] [Produces(XlsxContentType)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Routes([FromBody] RouteExportRequest request) public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
{ {
if (request.Sheets is not { Count: > 0 }) var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } }); 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"); var disposition = new ContentDispositionHeaderValue("attachment");
disposition.SetHttpFileName(RouteExportWorkbook.FileName); disposition.SetHttpFileName(file.FileName);
Response.Headers.ContentDisposition = disposition.ToString(); Response.Headers.ContentDisposition = disposition.ToString();
return File(file, XlsxContentType); return File(file.Content, XlsxContentType);
} }
} }
+4 -1
View File
@@ -53,7 +53,10 @@ builder.Services.AddCors(options =>
options.AddPolicy(CorsPolicyName, policy => policy options.AddPolicy(CorsPolicyName, policy => policy
.WithOrigins(corsAllowedOrigins) .WithOrigins(corsAllowedOrigins)
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod()); .AllowAnyMethod()
// Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа,
// а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов).
.WithExposedHeaders("Content-Disposition"));
}); });
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
@@ -3,6 +3,9 @@
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" /> <ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" /> <ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Update="appsettings.*.json" DependentUpon="appsettings.json" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Extensions.Hosting.AsyncInitialization" /> <PackageReference Include="Extensions.Hosting.AsyncInitialization" />
<PackageReference Include="Hangfire.AspNetCore" /> <PackageReference Include="Hangfire.AspNetCore" />
@@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<AutoApproveService>(); services.AddScoped<AutoApproveService>();
services.AddScoped<AutoApproveJob>(); services.AddScoped<AutoApproveJob>();
services.AddScoped<VacancyReportService>(); services.AddScoped<VacancyReportService>();
services.AddScoped<RouteExportService>();
services.AddScoped<TemplateRouteService>(); services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>(); services.AddScoped<TemplateRouteStepService>();
@@ -0,0 +1,312 @@
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;
/// <summary>Готовый файл выгрузки и его имя.</summary>
public sealed record RouteExportFile(byte[] Content, string FileName);
/// <summary>
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
/// организация, условие, комментарий) и отдаёт на вёрстку в
/// <see cref="RouteExportWorkbook"/>.
///
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
/// <see cref="RouteStepStatus"/>), у каждой своя нумерация.
/// </summary>
public sealed class RouteExportService(
ApprovalRouteDbContext db,
ITnsuPortalClient portalClient,
IOptions<TnsuPortalOptions> 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 = "Определяется по заявке";
/// <summary>
/// Технические имена колонок списка, которые в таблице показываем по-человечески
/// (CompaniesJson — организации заявки).
/// </summary>
private static readonly Dictionary<string, string> ConditionFieldAliases = new(StringComparer.Ordinal)
{
["CompaniesJson"] = "организация"
};
private static readonly Dictionary<ProjectRole, string> ProjectRoleLabels = new()
{
[ProjectRole.ProjectManager] = "Руководитель проекта",
[ProjectRole.Planner] = "Планировщик",
[ProjectRole.ManagerHSE] = "Менеджер HSE",
[ProjectRole.Security] = "Безопасность",
[ProjectRole.Keeper] = "Заведующий склада"
};
private static readonly Dictionary<HeadType, string> HeadTypeLabels = new()
{
[HeadType.Chief] = "Непосредственный руководитель",
[HeadType.DirectionHead] = "Руководитель направления",
[HeadType.DirectorTansu] = "Директор Tansu",
[HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu",
[HeadType.DepartmentHead] = "Руководитель указанного отдела",
[HeadType.CompanyDirectors] = "Директора компаний инициатора"
};
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
{
[ComparisonOperator.GreaterThan] = ">",
[ComparisonOperator.LessThan] = "<",
[ComparisonOperator.Equal] = "=",
[ComparisonOperator.NotEqual] = "≠",
[ComparisonOperator.Contains] = "содержит",
[ComparisonOperator.NotContains] = "не содержит"
};
public async Task<Result<RouteExportFile>> BuildAsync(IReadOnlyList<int>? 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))));
}
/// <summary>
/// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках
/// сокращённые названия типов (не больше <see cref="MaxTypesInFileName"/>, остальные
/// сворачиваются в «и др.»), в конце — дата выгрузки.
/// </summary>
public static string FileName(IReadOnlyList<string> 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<PortalDirectory> 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<TemplateRoute> 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<RouteExportGroup> BuildGroups(IEnumerable<TemplateRouteStep> 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<RouteExportRow> BuildRows(IReadOnlyList<TemplateRouteStep> 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<EmployeeContactInfo> employees,
IReadOnlyDictionary<Guid, string> companies,
IReadOnlyDictionary<Guid, string> 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];
}
}
@@ -4,28 +4,37 @@ using NPOI.XSSF.UserModel;
namespace Tnsu.ApprovalRoute.Application.Reports; namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка).</summary> /// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
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);
/// <summary>Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация).</summary>
public sealed record RouteExportGroup(string Caption, IReadOnlyList<RouteExportRow> Rows);
/// <summary> /// <summary>
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
/// (по компании/отделу) и их нужно различать на одном листе; у единственного /// (по компании/отделу) и их нужно различать на одном листе; у единственного
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
/// </summary> /// </summary>
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportRow> Rows); public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
/// <summary>Лист выгрузки — один тип заявки.</summary> /// <summary>Лист выгрузки — один тип заявки.</summary>
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections); public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
public sealed record RouteExportRequest(IReadOnlyList<RouteExportSheet> Sheets); /// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
/// <summary> /// <summary>
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: /// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». /// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
/// ///
/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: /// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
/// там уже есть справочники сотрудников и логика описания шага, дублировать их /// <see cref="RouteExportService"/>.
/// на бэкенде незачем. Здесь только вёрстка файла.
/// </summary> /// </summary>
public static class RouteExportWorkbook public static class RouteExportWorkbook
{ {
@@ -37,12 +46,12 @@ public static class RouteExportWorkbook
(" ", 6.0), (" ", 6.0),
("Роль ", 41.1), ("Роль ", 41.1),
("Исполнитель ", 42.2), ("Исполнитель ", 42.2),
("Организация ", 28.0),
("Условие ", 66.2), ("Условие ", 66.2),
("Комментарий ", 36.0),
]; ];
public const string FileName = "Маршруты согласования.xlsx"; public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
public static byte[] Build(RouteExportRequest request)
{ {
var workbook = new XSSFWorkbook(); var workbook = new XSSFWorkbook();
@@ -51,7 +60,7 @@ public static class RouteExportWorkbook
var titleStyle = CreateStyle(workbook, bold: true, border: false); var titleStyle = CreateStyle(workbook, bold: true, border: false);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var sheetData in request.Sheets) foreach (var sheetData in sheets)
{ {
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
for (var i = 0; i < Columns.Length; i++) for (var i = 0; i < Columns.Length; i++)
@@ -60,7 +69,7 @@ public static class RouteExportWorkbook
var rowIndex = 0; var rowIndex = 0;
foreach (var section in sheetData.Sections) foreach (var section in sheetData.Sections)
{ {
if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами
if (!string.IsNullOrWhiteSpace(section.Title)) if (!string.IsNullOrWhiteSpace(section.Title))
{ {
@@ -72,23 +81,43 @@ public static class RouteExportWorkbook
rowIndex++; rowIndex++;
} }
var header = sheet.CreateRow(rowIndex++); // Шапка колонок — один раз на маршрут: исполнители идут сразу под
for (var i = 0; i < Columns.Length; i++) // согласующими, их отделяет только строка-подпись.
var firstGroup = true;
foreach (var group in section.Groups)
{ {
var cell = header.CreateCell(i); var captionRow = sheet.CreateRow(rowIndex);
cell.SetCellValue(Columns[i].Title); var captionCell = captionRow.CreateCell(0);
cell.CellStyle = headerStyle; captionCell.SetCellValue(group.Caption);
} captionCell.CellStyle = titleStyle;
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
rowIndex++;
foreach (var row in section.Rows) if (firstGroup)
{ {
var excelRow = sheet.CreateRow(rowIndex++); var header = sheet.CreateRow(rowIndex++);
var numberCell = excelRow.CreateCell(0); for (var i = 0; i < Columns.Length; i++)
numberCell.SetCellValue(row.Number); {
numberCell.CellStyle = cellStyle; var cell = header.CreateCell(i);
WriteText(excelRow, 1, row.Role, cellStyle); cell.SetCellValue(Columns[i].Title);
WriteText(excelRow, 2, row.Executor, cellStyle); cell.CellStyle = headerStyle;
WriteText(excelRow, 3, row.Condition, cellStyle); }
}
firstGroup = false;
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);
}
} }
} }
} }
@@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) => public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken)); GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
public Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken));
public Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken));
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync( GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
return employees return employees
.Where(e => !string.IsNullOrWhiteSpace(e.Fio)) .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<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken)
{
var companies = await GetAsync<List<DictionaryItemResponse>>("/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<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken)
{
var departments = await GetAsync<List<DictionaryItemResponse>>("/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(); .ToList();
} }
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
[property: JsonPropertyName("fio")] string? Fio, [property: JsonPropertyName("fio")] string? Fio,
[property: JsonPropertyName("id")] Guid? Id = null); [property: JsonPropertyName("id")] Guid? Id = null);
private sealed record DictionaryItemResponse(
[property: JsonPropertyName("id")] Guid? Id,
[property: JsonPropertyName("name")] string? Name);
private sealed record ManagerResponse( private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail); [property: JsonPropertyName("managerEmail")] string? ManagerEmail);
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
/// </summary> /// </summary>
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken); Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
/// <summary>Справочник компаний — /Dictionary/Companies.</summary>
Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken);
/// <summary>Справочник отделов — /Dictionary/Departments.</summary>
Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно /// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
/// по <paramref name="Id"/> — он один и тот же в обоих ответах. /// по <paramref name="Id"/> — он один и тот же в обоих ответах.
/// </summary> /// </summary>
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);
/// <summary>Компания из справочника /Dictionary/Companies.</summary>
public sealed record CompanyInfo(Guid Id, string Name);
/// <summary>Отдел из справочника /Dictionary/Departments.</summary>
public sealed record DepartmentInfo(Guid Id, string Name);
@@ -186,7 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
route={route.resolve} route={route.resolve}
/> />
)} )}
{route.tab === 'routesExport' && <RoutesExportScreen api={api} dictionary={dictionary} />} {route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
{route.tab === 'manageRequests' && ( {route.tab === 'manageRequests' && (
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} /> <ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
)} )}
@@ -1,20 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { reportError } from '../../utils/errors'; import { reportError } from '../../utils/errors';
import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport';
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
export interface IRoutesExportScreenProps { export interface IRoutesExportScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
} }
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
// делают по одному и тому же набору типов.
const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected';
function download(blob: Blob, fileName: string): void { function download(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
@@ -26,45 +25,30 @@ function download(blob: Blob, fileName: string): void {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, dictionary }) => { export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
const requestTypes = useRequestTypes(api); const requestTypes = useRequestTypes(api);
const employees = usePortalEmployees(dictionary);
const portalDictionaries = usePortalDictionaries(dictionary);
const [selected, setSelected] = React.useState<number[]>([]); const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
const [isBusy, setIsBusy] = React.useState(false); const [isBusy, setIsBusy] = React.useState(false);
const [error, setError] = React.useState<string | undefined>(); const [error, setError] = React.useState<string | undefined>();
const allIds = requestTypes.items.map(rt => rt.id); const allIds = requestTypes.items.map(rt => rt.id);
// Удалённые с тех пор типы из сохранённого выбора не считаем.
const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1);
const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1); const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1);
const toggle = (id: number, checked: boolean): void => const toggle = (id: number, checked: boolean): void =>
setSelected(current => (checked ? [...current, id] : current.filter(x => x !== id))); setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id)));
const runExport = (): void => { const runExport = (): void => {
setError(undefined); setError(undefined);
setIsBusy(true); setIsBusy(true);
// Порядок листов — как в списке типов, а не в порядке кликов. api.reports.routes(selected)
const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1); .then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
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 }))
.then(blob => download(blob, texts.routesExport.fileName))
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
.then(() => setIsBusy(false), () => setIsBusy(false)); .then(() => setIsBusy(false), () => setIsBusy(false));
}; };
// Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться.
const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading;
return ( return (
<div> <div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} /> <Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
@@ -76,7 +60,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
<Button <Button
variant="accent" variant="accent"
icon="check" icon="check"
disabled={selected.length === 0 || isBusy || dictionariesLoading} disabled={selected.length === 0 || isBusy}
onClick={runExport} onClick={runExport}
> >
{isBusy ? texts.routesExport.exporting : texts.routesExport.export} {isBusy ? texts.routesExport.exporting : texts.routesExport.export}
@@ -85,7 +69,6 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
/> />
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>} {requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
{employees.error && <Alert kind="err">{employees.error}</Alert>}
{error && <Alert kind="err">{error}</Alert>} {error && <Alert kind="err">{error}</Alert>}
<Panel> <Panel>
@@ -9,4 +9,3 @@ export * from './templateRouteSteps';
export * from './resolve'; export * from './resolve';
export * from './requestManage'; export * from './requestManage';
export * from './manageByUser'; export * from './manageByUser';
export * from './routeExport';
@@ -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[];
}
@@ -1,4 +1,4 @@
import { IHttpClient } from './httpClient'; import { IDownloadedFile, IHttpClient } from './httpClient';
import { import {
CreateRequestTypeAccessInput, CreateRequestTypeAccessInput,
CreateRequestTypeInput, CreateRequestTypeInput,
@@ -13,7 +13,6 @@ import {
PatchTemplateRouteInput, PatchTemplateRouteInput,
PatchTemplateRouteStepInput, PatchTemplateRouteStepInput,
RequestListPartition, RequestListPartition,
RouteExportRequest,
RequestType, RequestType,
RequestTypeAccess, RequestTypeAccess,
RequestTypeListSeries, RequestTypeListSeries,
@@ -176,7 +175,7 @@ export class ApprovalRouteApi {
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON. // Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
public readonly reports = { public readonly reports = {
routes: (body: RouteExportRequest): Promise<Blob> => routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
this.http.postForBlob('/api/reports/routes', body) this.http.postForBlob('/api/reports/routes', { requestTypeIds })
}; };
} }
@@ -18,6 +18,28 @@ export class ApiClientError extends Error {
} }
} }
/** Скачанный файл; fileName пуст, если сервер не прислал Content-Disposition. */
export interface IDownloadedFile {
blob: Blob;
fileName?: string;
}
// filename*=UTF-8''... (RFC 5987, так отдаёт ASP.NET для кириллицы) или обычный filename=...
// eslint-disable-next-line @rushstack/no-new-null -- header — результат Headers.get() (DOM API)
function fileNameFromDisposition(header: string | null | undefined): string | undefined {
if (!header) return undefined;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (encoded) {
try {
return decodeURIComponent(encoded[1]);
} catch {
// Битая кодировка — падаем на обычный filename ниже.
}
}
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain ? plain[1] : undefined;
}
export type QueryParams = Record<string, string | number | boolean | undefined>; export type QueryParams = Record<string, string | number | boolean | undefined>;
export interface IHttpClient { export interface IHttpClient {
@@ -27,7 +49,7 @@ export interface IHttpClient {
patch<T>(path: string, body?: unknown): Promise<T>; patch<T>(path: string, body?: unknown): Promise<T>;
delete(path: string): Promise<void>; delete(path: string): Promise<void>;
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */ /** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
postForBlob(path: string, body: unknown): Promise<Blob>; postForBlob(path: string, body: unknown): Promise<IDownloadedFile>;
} }
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string { function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
@@ -107,7 +129,7 @@ export class AadApiHttpClient implements IHttpClient {
await this.send<void>('DELETE', path); await this.send<void>('DELETE', path);
} }
public async postForBlob(path: string, body: unknown): Promise<Blob> { public async postForBlob(path: string, body: unknown): Promise<IDownloadedFile> {
const url = buildUrl(this.baseUrl, path); const url = buildUrl(this.baseUrl, path);
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, { const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
method: 'POST', method: 'POST',
@@ -119,7 +141,7 @@ export class AadApiHttpClient implements IHttpClient {
await parseResponse<void>(response); await parseResponse<void>(response);
throw new ApiClientError(response.status, [`HTTP ${response.status}`]); throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
} }
return response.blob(); return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) };
} }
private send<T>(method: string, path: string, body?: unknown): Promise<T> { private send<T>(method: string, path: string, body?: unknown): Promise<T> {
+1 -5
View File
@@ -211,16 +211,12 @@ export const texts = {
breadcrumb: 'Выгрузка маршрутов', breadcrumb: 'Выгрузка маршрутов',
subtitle: 'Маршруты согласования в Excel', subtitle: 'Маршруты согласования в Excel',
title: 'Выгрузка маршрутов', title: 'Выгрузка маршрутов',
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).', hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).',
selectAll: 'Выбрать все', selectAll: 'Выбрать все',
export: 'Выгрузить', export: 'Выгрузить',
exporting: 'Формирование файла...', exporting: 'Формирование файла...',
emptyTypes: 'Типов заявок пока нет.', emptyTypes: 'Типов заявок пока нет.',
selected: (count: number): string => `Выбрано: ${count}`, selected: (count: number): string => `Выбрано: ${count}`,
routeTitle: (company: string, department: string, byProject: boolean): string =>
`Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`,
always: 'Всегда',
executorByRequest: 'Определяется по заявке',
fileName: 'Маршруты согласования.xlsx', fileName: 'Маршруты согласования.xlsx',
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}` failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
}, },
@@ -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) }))
};
}