Выгрузка маршрутов: в имени файла сокращённые типы заявок (до 7) и дата

Имя формирует бэкенд и отдаёт в Content-Disposition (он добавлен в
экспонируемые заголовки CORS), фронт сохраняет файл под этим именем.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-13 10:40:19 +05:00
parent dce71ab672
commit bacf18c608
7 changed files with 81 additions and 13 deletions
@@ -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);
}
}
+4 -1
View File
@@ -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);
@@ -8,6 +8,9 @@ using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>Готовый файл выгрузки и его имя.</summary>
public sealed record RouteExportFile(byte[] Content, string FileName);
/// <summary>
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
@@ -17,11 +20,15 @@ namespace Tnsu.ApprovalRoute.Application.Reports;
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
/// <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 = "любая компания";
@@ -66,7 +73,7 @@ public sealed class RouteExportService(
[ComparisonOperator.NotContains] = "не содержит"
};
public async Task<Result<byte[]>> BuildAsync(IReadOnlyList<int>? requestTypeIds, CancellationToken cancellationToken)
public async Task<Result<RouteExportFile>> BuildAsync(IReadOnlyList<int>? 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))));
}
/// <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)
@@ -51,8 +51,6 @@ public static class RouteExportWorkbook
("Комментарий ", 36.0),
];
public const string FileName = "Маршруты согласования.xlsx";
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
{
var workbook = new XSSFWorkbook();
@@ -44,7 +44,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ 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));
};
@@ -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<Blob> =>
routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
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 interface IHttpClient {
@@ -27,7 +49,7 @@ export interface IHttpClient {
patch<T>(path: string, body?: unknown): Promise<T>;
delete(path: string): Promise<void>;
/** 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 {
@@ -107,7 +129,7 @@ export class AadApiHttpClient implements IHttpClient {
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 response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
method: 'POST',
@@ -119,7 +141,7 @@ export class AadApiHttpClient implements IHttpClient {
await parseResponse<void>(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<T>(method: string, path: string, body?: unknown): Promise<T> {