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 {