using NPOI.SS.UserModel; using NPOI.SS.Util; using NPOI.XSSF.UserModel; namespace Tnsu.ApprovalRoute.Application.Reports; /// Строка выгрузки маршрута: шаг в понятном человеку виде. public sealed record RouteExportRow( int Number, string Role, string Executor, string Organization, string Condition, string Comment); /// Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация). public sealed record RouteExportGroup(string Caption, IReadOnlyList Rows); /// /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// (по компании/отделу) и их нужно различать на одном листе; у единственного /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// public sealed record RouteExportSection(string? Title, IReadOnlyList Groups); /// Лист выгрузки — один тип заявки. public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); /// Тело запроса POST api/reports/routes — какие типы заявок выгрузить. public sealed record RouteExportRequest(IReadOnlyList? RequestTypeIds); /// /// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: /// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». /// /// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает /// . /// 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 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(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 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(); }