Выгрузка маршрутов: в имени файла сокращённые типы заявок (до 7) и дата
Имя формирует бэкенд и отдаёт в Content-Disposition (он добавлен в экспонируемые заголовки CORS), фронт сохраняет файл под этим именем. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user