Выгрузка маршрутов: сборка таблицы перенесена на бэкенд, добавлены организация, комментарий и деление на согласующих/исполнителей

Бэкенд сам читает маршруты, шаги и справочники портала (сотрудники с должностями,
компании, отделы), фронт шлёт только id типов заявок и скачивает файл.
Колонки: №, Роль, Исполнитель, Организация, Условие, Комментарий.
Согласующие и исполнители — отдельными таблицами. Условие по CompaniesJson
показывается как «организация». Директор Tansu и Управляющий директор Tansu
выводятся с ФИО. Комментарий «назначает согласующего/исполнителя» — при галочке
переназначения.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-13 10:24:18 +05:00
parent 43b3c77ac1
commit 6433299c62
15 changed files with 357 additions and 167 deletions
@@ -4,28 +4,37 @@ using NPOI.XSSF.UserModel;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка).</summary>
public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition);
/// <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<RouteExportRow> Rows);
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
/// <summary>Лист выгрузки — один тип заявки.</summary>
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
public sealed record RouteExportRequest(IReadOnlyList<RouteExportSheet> Sheets);
/// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
public sealed record RouteExportRequest(IReadOnlyList<int> RequestTypeIds);
/// <summary>
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
///
/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки:
/// там уже есть справочники сотрудников и логика описания шага, дублировать их
/// на бэкенде незачем. Здесь только вёрстка файла.
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
/// <see cref="RouteExportService"/>.
/// </summary>
public static class RouteExportWorkbook
{
@@ -37,12 +46,14 @@ public static class RouteExportWorkbook
(" ", 6.0),
("Роль ", 41.1),
("Исполнитель ", 42.2),
("Организация ", 28.0),
("Условие ", 66.2),
("Комментарий ", 36.0),
];
public const string FileName = "Маршруты согласования.xlsx";
public static byte[] Build(RouteExportRequest request)
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
{
var workbook = new XSSFWorkbook();
@@ -51,7 +62,7 @@ public static class RouteExportWorkbook
var titleStyle = CreateStyle(workbook, bold: true, border: false);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var sheetData in request.Sheets)
foreach (var sheetData in sheets)
{
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
for (var i = 0; i < Columns.Length; i++)
@@ -60,8 +71,6 @@ public static class RouteExportWorkbook
var rowIndex = 0;
foreach (var section in sheetData.Sections)
{
if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами
if (!string.IsNullOrWhiteSpace(section.Title))
{
var titleRow = sheet.CreateRow(rowIndex);
@@ -72,23 +81,37 @@ public static class RouteExportWorkbook
rowIndex++;
}
var header = sheet.CreateRow(rowIndex++);
for (var i = 0; i < Columns.Length; i++)
foreach (var group in section.Groups)
{
var cell = header.CreateCell(i);
cell.SetCellValue(Columns[i].Title);
cell.CellStyle = headerStyle;
}
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++;
foreach (var row in section.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.Condition, cellStyle);
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;
}
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);
}
rowIndex++; // пустая строка между таблицами
}
}
}