using NPOI.OOXML.XSSF.UserModel; using NPOI.SS.UserModel; using NPOI.SS.UserModel.Charts; using NPOI.SS.Util; using NPOI.XSSF.UserModel; namespace Tnsu.ApprovalRoute.Application.Reports; /// /// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR: /// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по /// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя /// круговыми диаграммами). /// /// Листы собираются кодом, а не заполнением файла-шаблона: число строк /// переменное (список вакансий) или зависит от состава рекрутёров (сводка) — /// шаблон с такой структурой пришлось бы править на каждое изменение состава. /// /// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов /// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный /// лист выглядел бы просто как две таблицы без визуализации, как в образце. /// /// Производные колонки (G «в работе», J «срок отработки») и итоги первого /// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше /// руками, и после правки строки цифры должны пересчитаться сами. /// public static class VacancyReportWorkbook { private const string SheetName = "Список вакансий (все)"; private const string SummarySheetName = "Результаты"; private const string DateFormat = "dd.mm.yyyy"; private const string WholePercentFormat = "0%"; private const string PrecisePercentFormat = "0.00%"; /// Колонка первого листа: заголовок и ширина (ширины — из образца отчёта). private static readonly (string Title, double Width)[] Columns = [ (" ", 6.1), ("№", 4.9), ("Вакансия", 57.4), ("Подразделение", 17.1), ("Кол-во вакансий", 8.3), ("количество закрытых вакансий", 9.1), ("количество вакансий в работе", 11.6), ("Дата подачи заявки", 14.7), ("Дата закрытия вакансии", 14.9), ("Срок отработки", 12.3), ("Дата выхода", 13.9), ("Инициатор заявки", 26.9), ("Вместо сотрудника Ф.И.О.", 26.0), ("Комментарии", 51.3), ("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0), ("Рекрутер", 23.9), ]; // Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.). private const int ColTotalNumber = 0; private const int ColGroupNumber = 1; private const int ColPosition = 2; private const int ColDepartment = 3; private const int ColVacancies = 4; private const int ColClosed = 5; private const int ColInProgress = 6; private const int ColSubmitDate = 7; private const int ColClosingDate = 8; private const int ColLeadTime = 9; private const int ColStartDate = 10; private const int ColInitiator = 11; private const int ColReplaced = 12; private const int ColClosedFlag = 14; private const int ColRecruiter = 15; private const int HeaderRowIdx = 0; public static byte[] Build(VacancyReport report) { using var workbook = new XSSFWorkbook(); var styles = new Styles(workbook); BuildVacancyListSheet(workbook, styles, report); BuildSummarySheet(workbook, styles, report); using var stream = new MemoryStream(); workbook.Write(stream, leaveOpen: true); return stream.ToArray(); } /// Имя файла — с периодом, как в ручном отчёте HR. public static string FileName(VacancyReport report) => $"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx"; // ==================== Лист 1: «Список вакансий (все)» ==================== private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report) { var sheet = (XSSFSheet)workbook.CreateSheet(SheetName); WriteHeader(sheet, styles); var rowIdx = HeaderRowIdx + 1; var totalNumber = 0; var firstDataRowIdx = rowIdx; foreach (var group in report.Groups) { if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name); var groupNumber = 0; foreach (var item in group.Rows) WriteRow(sheet, styles, rowIdx++, ++totalNumber, ++groupNumber, item); } var lastDataRowIdx = rowIdx - 1; // Итоги — только если есть что суммировать: SUM по пустому диапазону // (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном. if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx); FinishVacancyListSheet(sheet, styles, lastDataRowIdx); } private static void WriteHeader(XSSFSheet sheet, Styles styles) { var row = sheet.CreateRow(HeaderRowIdx); // Заголовки длинные и переносятся — без заданной высоты Excel показал бы // одну строку из трёх (высота 90 — из образца отчёта). row.HeightInPoints = 90f; for (var col = 0; col < Columns.Length; col++) { var (title, width) = Columns[col]; var cell = row.CreateCell(col); cell.SetCellValue(title); cell.CellStyle = styles.Header; sheet.SetColumnWidth(col, (int)Math.Round(width * 256)); } } /// /// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в /// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё /// начинается заново — так же, как в ручном отчёте. /// private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name) { var row = sheet.CreateRow(rowIdx); for (var col = 0; col < Columns.Length; col++) row.CreateCell(col).CellStyle = styles.GroupSeparator; row.GetCell(ColGroupNumber).SetCellValue(name); } private static void WriteRow( XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item) { var row = sheet.CreateRow(rowIdx); var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки. for (var col = 0; col < Columns.Length; col++) row.CreateCell(col).CellStyle = styles.Data; row.GetCell(ColTotalNumber).SetCellValue(totalNumber); row.GetCell(ColGroupNumber).SetCellValue(groupNumber); row.GetCell(ColPosition).SetCellValue(item.Position); row.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty); row.GetCell(ColVacancies).SetCellValue(item.VacanciesCount); row.GetCell(ColClosed).SetCellValue(item.ClosedCount); row.GetCell(ColInProgress).SetCellFormula($"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}"); SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate); SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate); // «Срок отработки» — разница дат в днях, и только когда известны обе: // иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч // дней (пустая ячейка — это 00.01.1900). if (item.SubmitDate is not null && item.ClosingDate is not null) { var leadTimeCell = row.GetCell(ColLeadTime); leadTimeCell.CellStyle = styles.DataLeadTime; leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}"); } SetDate(row.GetCell(ColStartDate), styles, item.StartDate); row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty); row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty); // Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в // ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым // в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали // в выгрузке. row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag); row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty); } private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx) { var row = sheet.CreateRow(rowIdx); var firstExcelRow = firstDataRowIdx + 1; var lastExcelRow = lastDataRowIdx + 1; for (var col = 0; col < Columns.Length; col++) row.CreateCell(col).CellStyle = styles.Totals; foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress]) { var letter = ColLetter(col); row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})"); } } /// /// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр /// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида /// вместе с отфильтрованными строками. /// private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx) { // Даты — дефолтным стилем колонки, а не только по заполненным ячейкам: // формат должен остаться и у строк, которые допишут руками. foreach (var col in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) sheet.SetDefaultColumnStyle(col, styles.DataDate); // Шапка закреплена — лист длинный, без этого при прокрутке не видно, // какая колонка какая. sheet.CreateFreezePane(0, HeaderRowIdx + 1); if (lastDataRowIdx > HeaderRowIdx) sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1)); } private static void SetDate(ICell cell, Styles styles, DateOnly? date) { if (date is null) return; cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue)); cell.CellStyle = styles.DataDate; } // ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ==================== /// /// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт, /// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого /// рекрутёра в общем объёме — они же источник данных для двух круговых /// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в /// ручном отчёте HR. /// private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report) { var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName); sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256)); var titleRow = sheet.CreateRow(0); titleRow.CreateCell(0).SetCellValue("результаты"); titleRow.GetCell(0).CellStyle = styles.Title; var summary = VacancyReportSummary.Build(report); if (summary.Recruiters.Count == 0) { sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период."); return; } var recruiters = summary.Recruiters; for (var i = 0; i < recruiters.Count; i++) sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256)); WriteHorizontalTable(sheet, styles, summary); const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто» var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1; WriteVerticalTable( sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal, recruiters, r => r.ShareOfTotal(summary.GrandTotal)); var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1; var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1; WriteVerticalTable( sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress, recruiters, r => r.ShareOfInProgress(summary.GrandInProgress)); var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}"; CreatePieChart( sheet, "Объём вакансий " + period, firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable1HeaderRowIdx); CreatePieChart( sheet, "Объём вакансий в работе " + period, firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable2HeaderRowIdx); } /// /// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры /// колонками, как в образце отчёта (там же W:Y). /// private static void WriteHorizontalTable(XSSFSheet sheet, Styles styles, VacancyReportSummary summary) { var headerRow = sheet.CreateRow(HeaderRowIdx + 1); var totalRow = sheet.CreateRow(HeaderRowIdx + 2); var inProgressRow = sheet.CreateRow(HeaderRowIdx + 3); var closedRow = sheet.CreateRow(HeaderRowIdx + 4); SetCell(headerRow, 0, "Кол-во вакансий", styles.SummaryHeaderYellow); SetCell(totalRow, 0, "Всего", styles.SummaryLabel); SetCell(inProgressRow, 0, "в работе (в%)", styles.SummaryRowGreenLabel); SetCell(closedRow, 0, "закрыто", styles.SummaryLabel); for (var i = 0; i < summary.Recruiters.Count; i++) { var r = summary.Recruiters[i]; var col = 1 + i; SetCell(headerRow, col, r.Name, styles.SummaryHeaderYellow); SetCell(totalRow, col, r.Total, styles.SummaryValue); SetCell(inProgressRow, col, r.InProgressRate, styles.SummaryRowGreenPercent); SetCell(closedRow, col, r.ClosedRate, styles.SummaryPercent); } } /// /// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого — /// красным полужирным, как в образце) плюс по строке на рекрутёра с его /// долей в этом итого. Возвращаемый диапазон строк — вход для круговой /// диаграммы (см. CreatePieChart). /// private static void WriteVerticalTable( XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total, IReadOnlyList recruiters, Func share) { var headerRow = sheet.CreateRow(headerRowIdx); SetCell(headerRow, 0, label, styles.SummaryBlueHeader); SetCell(headerRow, 1, total, styles.SummaryBlueTotal); for (var i = 0; i < recruiters.Count; i++) { var row = sheet.CreateRow(headerRowIdx + 1 + i); SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText); SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent); } } /// /// Круговая диаграмма по одной вертикальной таблице: категории — колонка A /// (имена рекрутёров), значения — колонка B (доля). Высота якоря — /// фиксированные 14 строк: чтения диаграммы ради нет смысла подгонять её /// размер под число рекрутёров, малое число категорий на большом круге /// читается ничуть не хуже. /// private static void CreatePieChart( XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx) { if (rowCount == 0) return; var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch(); var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + 14); var chart = (XSSFChart)drawing.CreateChart(anchor); chart.SetTitle(title); var lastDataRowIdx = firstDataRowIdx + rowCount - 1; var data = chart.ChartDataFactory.CreatePieChartData(); var categories = DataSources.FromStringCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 0, 0)); var values = DataSources.FromNumericCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 1, 1)); data.AddSeries(categories, values); chart.Plot(data); chart.GetOrCreateLegend(); } private static void SetCell(IRow row, int col, string value, ICellStyle style) { var cell = row.CreateCell(col); cell.SetCellValue(value); cell.CellStyle = style; } private static void SetCell(IRow row, int col, double value, ICellStyle style) { var cell = row.CreateCell(col); cell.SetCellValue(value); cell.CellStyle = style; } private static string ColLetter(int col0Based) => CellReference.ConvertNumToColString(col0Based); /// /// Все стили обоих листов — созданы один раз на книгу и переиспользуются: /// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а /// не собирается по кусочкам, поэтому под каждую итоговую комбинацию /// заливка+шрифт+формат нужен отдельный объект стиля. /// private sealed class Styles { public ICellStyle Header { get; } public ICellStyle GroupSeparator { get; } public ICellStyle Data { get; } public ICellStyle DataDate { get; } public ICellStyle DataLeadTime { get; } public ICellStyle Totals { get; } public ICellStyle Title { get; } public ICellStyle SummaryHeaderYellow { get; } public ICellStyle SummaryLabel { get; } public ICellStyle SummaryValue { get; } public ICellStyle SummaryPercent { get; } public ICellStyle SummaryRowGreenLabel { get; } public ICellStyle SummaryRowGreenPercent { get; } public ICellStyle SummaryBlueHeader { get; } public ICellStyle SummaryBlueTotal { get; } public ICellStyle SummaryGreenText { get; } public ICellStyle SummaryGreenPercent { get; } // Цвета — из образца отчёта (заливка шапки FFE5E5FF и т.д.), чтобы // выгрузка выглядела привычно рядом со старым файлом. private static readonly byte[] HeaderFill = [0xE5, 0xE5, 0xFF]; private static readonly byte[] GroupFill = [0xF2, 0xF2, 0xF2]; private static readonly byte[] YellowFill = [0xFF, 0xFF, 0x00]; private static readonly byte[] GreenFill = [0xC6, 0xE0, 0xB4]; private static readonly byte[] BlueFill = [0x4A, 0x7E, 0xBB]; private static readonly byte[] RedFont = [0xC0, 0x00, 0x00]; private static readonly byte[] WhiteFont = [0xFF, 0xFF, 0xFF]; public Styles(XSSFWorkbook workbook) { var colorMap = new DefaultIndexedColorMap(); ICellStyle New( byte[]? fill = null, bool bold = false, byte[]? fontColor = null, string? numberFormat = null, bool wrap = false, bool border = false, bool topDoubleBorder = false, HorizontalAlignment? align = null, VerticalAlignment? valign = null) { var style = (XSSFCellStyle)workbook.CreateCellStyle(); if (fill is not null) { style.SetFillForegroundColor(new XSSFColor(fill, colorMap)); style.FillPattern = FillPattern.SolidForeground; } if (bold || fontColor is not null) { var font = (XSSFFont)workbook.CreateFont(); font.IsBold = bold; if (fontColor is not null) font.SetColor(new XSSFColor(fontColor, colorMap)); style.SetFont(font); } if (numberFormat is not null) style.DataFormat = workbook.CreateDataFormat().GetFormat(numberFormat); if (wrap) style.WrapText = true; if (border) { style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin; } if (topDoubleBorder) style.BorderTop = BorderStyle.Double; if (align is not null) style.Alignment = align.Value; if (valign is not null) style.VerticalAlignment = valign.Value; return style; } Header = New( fill: HeaderFill, bold: true, wrap: true, border: true, align: HorizontalAlignment.Center, valign: VerticalAlignment.Center); GroupSeparator = New(fill: GroupFill, bold: true); Data = New(wrap: true, border: true); DataDate = New(wrap: true, border: true, numberFormat: DateFormat); DataLeadTime = New(wrap: true, border: true, numberFormat: "0"); Totals = New(bold: true, topDoubleBorder: true); Title = New(bold: true); SummaryHeaderYellow = New(fill: YellowFill, bold: true); SummaryLabel = New(bold: true); SummaryValue = New(); SummaryPercent = New(numberFormat: WholePercentFormat); SummaryRowGreenLabel = New(fill: GreenFill, bold: true); SummaryRowGreenPercent = New(fill: GreenFill, numberFormat: WholePercentFormat); SummaryBlueHeader = New(fill: BlueFill, bold: true, fontColor: WhiteFont); SummaryBlueTotal = New(fill: BlueFill, bold: true, fontColor: RedFont); SummaryGreenText = New(fill: GreenFill); SummaryGreenPercent = New(fill: GreenFill, numberFormat: PrecisePercentFormat); } } }