bacf18c608
Имя формирует бэкенд и отдаёт в Content-Disposition (он добавлен в экспонируемые заголовки CORS), фронт сохраняет файл под этим именем. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
173 lines
7.4 KiB
C#
173 lines
7.4 KiB
C#
using NPOI.SS.UserModel;
|
|
using NPOI.SS.Util;
|
|
using NPOI.XSSF.UserModel;
|
|
|
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
|
|
|
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
|
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>
|
|
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
|
|
|
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
|
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
|
|
|
|
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
|
|
public sealed record RouteExportRequest(IReadOnlyList<int>? RequestTypeIds);
|
|
|
|
/// <summary>
|
|
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
|
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
|
///
|
|
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
|
/// <see cref="RouteExportService"/>.
|
|
/// </summary>
|
|
public static class RouteExportWorkbook
|
|
{
|
|
private const int MaxSheetNameLength = 31;
|
|
private const string InvalidSheetNameChars = "[]:*?/\\";
|
|
|
|
private static readonly (string Title, double Width)[] Columns =
|
|
[
|
|
(" ", 6.0),
|
|
("Роль ", 41.1),
|
|
("Исполнитель ", 42.2),
|
|
("Организация ", 28.0),
|
|
("Условие ", 66.2),
|
|
("Комментарий ", 36.0),
|
|
];
|
|
|
|
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
|
|
{
|
|
var workbook = new XSSFWorkbook();
|
|
|
|
var headerStyle = CreateStyle(workbook, bold: true);
|
|
var cellStyle = CreateStyle(workbook, bold: false);
|
|
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
|
|
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var sheetData in sheets)
|
|
{
|
|
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
|
|
for (var i = 0; i < Columns.Length; i++)
|
|
sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256));
|
|
|
|
var rowIndex = 0;
|
|
foreach (var section in sheetData.Sections)
|
|
{
|
|
if (rowIndex > 0) rowIndex++; // пустая строка только между маршрутами
|
|
|
|
if (!string.IsNullOrWhiteSpace(section.Title))
|
|
{
|
|
var titleRow = sheet.CreateRow(rowIndex);
|
|
var titleCell = titleRow.CreateCell(0);
|
|
titleCell.SetCellValue(section.Title);
|
|
titleCell.CellStyle = titleStyle;
|
|
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
|
rowIndex++;
|
|
}
|
|
|
|
// Шапка колонок — один раз на маршрут: исполнители идут сразу под
|
|
// согласующими, их отделяет только строка-подпись.
|
|
var firstGroup = true;
|
|
foreach (var group in section.Groups)
|
|
{
|
|
var captionRow = sheet.CreateRow(rowIndex);
|
|
var captionCell = captionRow.CreateCell(0);
|
|
captionCell.SetCellValue(group.Caption);
|
|
captionCell.CellStyle = titleStyle;
|
|
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
|
|
rowIndex++;
|
|
|
|
if (firstGroup)
|
|
{
|
|
var header = sheet.CreateRow(rowIndex++);
|
|
for (var i = 0; i < Columns.Length; i++)
|
|
{
|
|
var cell = header.CreateCell(i);
|
|
cell.SetCellValue(Columns[i].Title);
|
|
cell.CellStyle = headerStyle;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
using var stream = new MemoryStream();
|
|
workbook.Write(stream, leaveOpen: true);
|
|
return stream.ToArray();
|
|
}
|
|
|
|
private static void WriteText(IRow row, int column, string value, ICellStyle style)
|
|
{
|
|
var cell = row.CreateCell(column);
|
|
cell.SetCellValue(value);
|
|
cell.CellStyle = style;
|
|
}
|
|
|
|
private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true)
|
|
{
|
|
var font = workbook.CreateFont();
|
|
font.IsBold = bold;
|
|
|
|
var style = workbook.CreateCellStyle();
|
|
style.SetFont(font);
|
|
style.WrapText = true;
|
|
style.VerticalAlignment = VerticalAlignment.Top;
|
|
if (border)
|
|
{
|
|
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
|
|
}
|
|
return style;
|
|
}
|
|
|
|
// Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра.
|
|
private static string UniqueSheetName(string raw, HashSet<string> used)
|
|
{
|
|
var cleaned = new string((raw ?? string.Empty)
|
|
.Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c)
|
|
.ToArray()).Trim();
|
|
if (cleaned.Length == 0) cleaned = "Лист";
|
|
|
|
var candidate = Truncate(cleaned, MaxSheetNameLength);
|
|
for (var n = 2; !used.Add(candidate); n++)
|
|
{
|
|
var suffix = $" ({n})";
|
|
candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
private static string Truncate(string value, int length) =>
|
|
value.Length <= length ? value : value[..length].TrimEnd();
|
|
}
|