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 Condition); /// /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// (по компании/отделу) и их нужно различать на одном листе; у единственного /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// public sealed record RouteExportSection(string? Title, IReadOnlyList Rows); /// Лист выгрузки — один тип заявки. public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); public sealed record RouteExportRequest(IReadOnlyList Sheets); /// /// Выгрузка маршрутов согласования в 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), ("Условие ", 66.2), ]; public const string FileName = "Маршруты согласования.xlsx"; public static byte[] Build(RouteExportRequest request) { 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 request.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 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 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); } } } 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(); }