diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index 5fb61b6..8a565f5 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs @@ -69,9 +69,9 @@ public sealed class ReportsController(VacancyReportService vacancies, RouteExpor var file = result.Value; var disposition = new ContentDispositionHeaderValue("attachment"); - disposition.SetHttpFileName(RouteExportWorkbook.FileName); + disposition.SetHttpFileName(file.FileName); Response.Headers.ContentDisposition = disposition.ToString(); - return File(file, XlsxContentType); + return File(file.Content, XlsxContentType); } } diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 2f2a8f8..ec6d3bd 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -53,7 +53,10 @@ builder.Services.AddCors(options => options.AddPolicy(CorsPolicyName, policy => policy .WithOrigins(corsAllowedOrigins) .AllowAnyHeader() - .AllowAnyMethod()); + .AllowAnyMethod() + // Без этого браузер не отдаёт скрипту Content-Disposition кросс-доменного ответа, + // а из него фронт берёт имя скачиваемого файла (выгрузка маршрутов). + .WithExposedHeaders("Content-Disposition")); }); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs index 306ef41..4bb1d5c 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs @@ -8,6 +8,9 @@ using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.Reports; +/// Готовый файл выгрузки и его имя. +public sealed record RouteExportFile(byte[] Content, string FileName); + /// /// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов /// заявок, превращает их в понятные человеку строки (роль, исполнитель, @@ -17,11 +20,15 @@ namespace Tnsu.ApprovalRoute.Application.Reports; /// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага, /// ), у каждой своя нумерация. /// + 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 = "любая компания"; @@ -66,7 +73,7 @@ public sealed class RouteExportService( [ComparisonOperator.NotContains] = "не содержит" }; - public async Task> BuildAsync(IReadOnlyList? requestTypeIds, CancellationToken cancellationToken) + public async Task> BuildAsync(IReadOnlyList? requestTypeIds, CancellationToken cancellationToken) { if (requestTypeIds is not { Count: > 0 }) return Result.Fail("Не выбрано ни одного типа заявки."); @@ -90,7 +97,45 @@ public sealed class RouteExportService( .Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory)) .ToList(); - return Result.Ok(RouteExportWorkbook.Build(sheets)); + 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) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs index 767bc08..14434c7 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -51,8 +51,6 @@ public static class RouteExportWorkbook ("Комментарий ", 36.0), ]; - public const string FileName = "Маршруты согласования.xlsx"; - public static byte[] Build(IReadOnlyList sheets) { var workbook = new XSSFWorkbook(); diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx index 4ed796a..1aa3daf 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -44,7 +44,7 @@ export const RoutesExportScreen: React.FC = ({ api }) setError(undefined); setIsBusy(true); api.reports.routes(selected) - .then(blob => download(blob, texts.routesExport.fileName)) + .then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName)) .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) .then(() => setIsBusy(false), () => setIsBusy(false)); }; diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 2448b37..b6ca4e1 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -1,4 +1,4 @@ -import { IHttpClient } from './httpClient'; +import { IDownloadedFile, IHttpClient } from './httpClient'; import { CreateRequestTypeAccessInput, CreateRequestTypeInput, @@ -175,7 +175,7 @@ export class ApprovalRouteApi { // Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON. public readonly reports = { - routes: (requestTypeIds: number[]): Promise => + routes: (requestTypeIds: number[]): Promise => this.http.postForBlob('/api/reports/routes', { requestTypeIds }) }; } diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts index db7c264..ed947e4 100644 --- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts +++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts @@ -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; export interface IHttpClient { @@ -27,7 +49,7 @@ export interface IHttpClient { patch(path: string, body?: unknown): Promise; delete(path: string): Promise; /** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */ - postForBlob(path: string, body: unknown): Promise; + postForBlob(path: string, body: unknown): Promise; } function buildUrl(baseUrl: string, path: string, query?: QueryParams): string { @@ -107,7 +129,7 @@ export class AadApiHttpClient implements IHttpClient { await this.send('DELETE', path); } - public async postForBlob(path: string, body: unknown): Promise { + public async postForBlob(path: string, body: unknown): Promise { const url = buildUrl(this.baseUrl, path); const response = await this.client.fetch(url, AadHttpClient.configurations.v1, { method: 'POST', @@ -119,7 +141,7 @@ export class AadApiHttpClient implements IHttpClient { await parseResponse(response); 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(method: string, path: string, body?: unknown): Promise {