diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 78f2889..97274c9 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -10,7 +10,6 @@ - @@ -35,8 +34,15 @@ + + + diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs new file mode 100644 index 0000000..af36437 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs @@ -0,0 +1,70 @@ +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// +/// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в +/// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в +/// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше +/// рисуется как две круговые диаграммы. +/// +/// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а +/// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр. +/// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же, +/// как единица подсчёта работает на первом листе. +/// +public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList Recruiters) +{ + /// Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами. + public static readonly VacancyReportSummary Empty = new(0, 0, []); + + public static VacancyReportSummary Build(VacancyReport report) + { + var rows = report.Groups.SelectMany(g => g.Rows).ToList(); + if (rows.Count == 0) return Empty; + + var recruiters = rows + .GroupBy(r => string.IsNullOrWhiteSpace(r.Recruiter) ? UnassignedLabel : r.Recruiter!.Trim()) + .Select(g => new + { + Name = g.Key, + Total = g.Sum(r => r.VacanciesCount), + Closed = g.Sum(r => r.ClosedCount), + }) + .ToList(); + + var grandTotal = recruiters.Sum(r => r.Total); + var grandInProgress = recruiters.Sum(r => r.Total - r.Closed); + + // «Без рекрутёра» — всегда последним и независимо от объёма: это не + // реальный участник рейтинга, чья позиция в сортировке имела бы смысл. + var ordered = recruiters + .OrderByDescending(r => r.Name != UnassignedLabel) + .ThenByDescending(r => r.Total) + .Select(r => new RecruiterBreakdown(r.Name, r.Total, r.Total - r.Closed, r.Closed)) + .ToList(); + + return new VacancyReportSummary(grandTotal, grandInProgress, ordered); + } + + /// Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя). + public const string UnassignedLabel = "Без рекрутёра"; +} + +/// +/// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода +/// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с +/// Total/InProgress при округлении. +/// +public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed) +{ + /// Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы). + public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0; + + /// Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы). + public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0; + + /// Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице. + public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0; + + /// Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице. + public double ClosedRate => Total > 0 ? (double)Closed / Total : 0; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs index b26e122..ea645e1 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -1,32 +1,39 @@ -using ClosedXML.Excel; +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 в том же порядке, строки -/// разделители по объектам, итоги внизу. +/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR: +/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по +/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя +/// круговыми диаграммами). /// -/// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное, -/// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой -/// пришлось бы править на каждое изменение состава вакансий. +/// Листы собираются кодом, а не заполнением файла-шаблона: число строк +/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) — +/// шаблон с такой структурой пришлось бы править на каждое изменение состава. /// -/// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся -/// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после -/// правки строки цифры должны пересчитаться сами. +/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов +/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный +/// лист выглядел бы просто как две таблицы без визуализации, как в образце. +/// +/// Производные колонки (G «в работе», J «срок отработки») и итоги первого +/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше +/// руками, и после правки строки цифры должны пересчитаться сами. /// public static class VacancyReportWorkbook { private const string SheetName = "Список вакансий (все)"; - - // Цвет шапки — из образца отчёта (fill FFE5E5FF), чтобы выгрузка выглядела - // привычно рядом со старым файлом. - private static readonly XLColor HeaderFill = XLColor.FromArgb(0xE5, 0xE5, 0xFF); - private static readonly XLColor GroupFill = XLColor.FromArgb(0xF2, 0xF2, 0xF2); + 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), @@ -47,56 +54,35 @@ public static class VacancyReportWorkbook ("Рекрутер", 23.9), ]; - // Номера колонок — именами, чтобы формулы и стили не разъезжались с Columns. - private const int ColTotalNumber = 1; - private const int ColGroupNumber = 2; - private const int ColPosition = 3; - private const int ColDepartment = 4; - private const int ColVacancies = 5; - private const int ColClosed = 6; - private const int ColInProgress = 7; - private const int ColSubmitDate = 8; - private const int ColClosingDate = 9; - private const int ColLeadTime = 10; - private const int ColStartDate = 11; - private const int ColInitiator = 12; - private const int ColReplaced = 13; - private const int ColComment = 14; - private const int ColClosedFlag = 15; - private const int ColRecruiter = 16; + // Индексы колонок — 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 HeaderRow = 1; + private const int HeaderRowIdx = 0; public static byte[] Build(VacancyReport report) { - using var workbook = new XLWorkbook(); - var sheet = workbook.AddWorksheet(SheetName); + using var workbook = new XSSFWorkbook(); + var styles = new Styles(workbook); - WriteHeader(sheet); - - var row = HeaderRow + 1; - var totalNumber = 0; - var firstDataRow = row; - - foreach (var group in report.Groups) - { - if (group.Name.Length > 0) WriteGroupSeparator(sheet, row++, group.Name); - - var groupNumber = 0; - foreach (var item in group.Rows) - WriteRow(sheet, row++, ++totalNumber, ++groupNumber, item); - } - - var lastDataRow = row - 1; - - // Итоги — только если есть что суммировать: SUM по пустому диапазону - // (firstDataRow > lastDataRow) дал бы формулу с перевёрнутым диапазоном. - if (lastDataRow >= firstDataRow) WriteTotals(sheet, row, firstDataRow, lastDataRow); - - Finish(sheet, lastDataRow); + BuildVacancyListSheet(workbook, styles, report); + BuildSummarySheet(workbook, styles, report); using var stream = new MemoryStream(); - workbook.SaveAs(stream); + workbook.Write(stream, leaveOpen: true); return stream.ToArray(); } @@ -104,29 +90,51 @@ public static class VacancyReportWorkbook public static string FileName(VacancyReport report) => $"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx"; - private static void WriteHeader(IXLWorksheet sheet) - { - for (var i = 0; i < Columns.Length; i++) - { - var (title, width) = Columns[i]; - var column = i + 1; + // ==================== Лист 1: «Список вакансий (все)» ==================== - sheet.Cell(HeaderRow, column).Value = title; - sheet.Column(column).Width = width; + 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 header = sheet.Range(HeaderRow, 1, HeaderRow, Columns.Length); - header.Style.Fill.BackgroundColor = HeaderFill; - header.Style.Font.Bold = true; - header.Style.Alignment.WrapText = true; - header.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; - header.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; - header.Style.Border.OutsideBorder = XLBorderStyleValues.Thin; - header.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + 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 — из образца отчёта). - sheet.Row(HeaderRow).Height = 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)); + } } /// @@ -134,91 +142,340 @@ public static class VacancyReportWorkbook /// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё /// начинается заново — так же, как в ручном отчёте. /// - private static void WriteGroupSeparator(IXLWorksheet sheet, int row, string name) + private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name) { - sheet.Cell(row, ColGroupNumber).Value = name; + var row = sheet.CreateRow(rowIdx); + for (var col = 0; col < Columns.Length; col++) + row.CreateCell(col).CellStyle = styles.GroupSeparator; - var range = sheet.Range(row, 1, row, Columns.Length); - range.Style.Fill.BackgroundColor = GroupFill; - range.Style.Font.Bold = true; + row.GetCell(ColGroupNumber).SetCellValue(name); } - private static void WriteRow(IXLWorksheet sheet, int row, int totalNumber, int groupNumber, VacancyReportRow item) + private static void WriteRow( + XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item) { - sheet.Cell(row, ColTotalNumber).Value = totalNumber; - sheet.Cell(row, ColGroupNumber).Value = groupNumber; - sheet.Cell(row, ColPosition).Value = item.Position; - sheet.Cell(row, ColDepartment).Value = item.Department ?? string.Empty; - sheet.Cell(row, ColVacancies).Value = item.VacanciesCount; - sheet.Cell(row, ColClosed).Value = item.ClosedCount; - sheet.Cell(row, ColInProgress).FormulaA1 = $"={Letter(ColVacancies)}{row}-{Letter(ColClosed)}{row}"; + var row = sheet.CreateRow(rowIdx); + var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки. - SetDate(sheet.Cell(row, ColSubmitDate), item.SubmitDate); - SetDate(sheet.Cell(row, ColClosingDate), item.ClosingDate); + 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) { - sheet.Cell(row, ColLeadTime).FormulaA1 = - $"={Letter(ColClosingDate)}{row}-{Letter(ColSubmitDate)}{row}"; - sheet.Cell(row, ColLeadTime).Style.NumberFormat.Format = "0"; + var leadTimeCell = row.GetCell(ColLeadTime); + leadTimeCell.CellStyle = styles.DataLeadTime; + leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}"); } - SetDate(sheet.Cell(row, ColStartDate), item.StartDate); + SetDate(row.GetCell(ColStartDate), styles, item.StartDate); - sheet.Cell(row, ColInitiator).Value = item.Initiator ?? string.Empty; - sheet.Cell(row, ColReplaced).Value = item.ReplacedEmployee ?? string.Empty; - // Колонка «Комментарии» остаётся пустой намеренно: в ручном отчёте это - // пометки рекрутёра («в подборе», «на стопе»), которым в заявках сейчас - // негде храниться. Колонку оставляем, чтобы её дописывали в выгрузке. - sheet.Cell(row, ColClosedFlag).Value = item.ClosedFlag; - sheet.Cell(row, ColRecruiter).Value = item.Recruiter ?? string.Empty; - - sheet.Range(row, 1, row, Columns.Length).Style.Alignment.WrapText = true; - sheet.Range(row, 1, row, Columns.Length).Style.Border.OutsideBorder = XLBorderStyleValues.Thin; - sheet.Range(row, 1, row, Columns.Length).Style.Border.InsideBorder = XLBorderStyleValues.Thin; + 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(IXLWorksheet sheet, int row, int firstDataRow, int lastDataRow) + private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx) { - foreach (var column in (int[])[ColVacancies, ColClosed, ColInProgress]) - sheet.Cell(row, column).FormulaA1 = - $"=SUM({Letter(column)}{firstDataRow}:{Letter(column)}{lastDataRow})"; + var row = sheet.CreateRow(rowIdx); + var firstExcelRow = firstDataRowIdx + 1; + var lastExcelRow = lastDataRowIdx + 1; - var range = sheet.Range(row, 1, row, Columns.Length); - range.Style.Font.Bold = true; - range.Style.Border.TopBorder = XLBorderStyleValues.Double; + 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})"); + } } /// - /// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её - /// захватывать не должен, иначе при любом фильтре итоги уезжают из вида + /// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр + /// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида /// вместе с отфильтрованными строками. /// - private static void Finish(IXLWorksheet sheet, int lastDataRow) + private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx) { - // Даты — по всей колонке, а не по заполненным ячейкам: формат должен - // остаться и у строк, которые допишут руками. - foreach (var column in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) - sheet.Column(column).Style.NumberFormat.Format = DateFormat; + // Даты — дефолтным стилем колонки, а не только по заполненным ячейкам: + // формат должен остаться и у строк, которые допишут руками. + foreach (var col in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) + sheet.SetDefaultColumnStyle(col, styles.DataDate); // Шапка закреплена — лист длинный, без этого при прокрутке не видно, // какая колонка какая. - sheet.SheetView.FreezeRows(HeaderRow); + sheet.CreateFreezePane(0, HeaderRowIdx + 1); - if (lastDataRow > HeaderRow) - sheet.Range(HeaderRow, 1, lastDataRow, Columns.Length).SetAutoFilter(); + if (lastDataRowIdx > HeaderRowIdx) + sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1)); } - private static void SetDate(IXLCell cell, DateOnly? date) + private static void SetDate(ICell cell, Styles styles, DateOnly? date) { if (date is null) return; - cell.Value = date.Value.ToDateTime(TimeOnly.MinValue); - cell.Style.NumberFormat.Format = DateFormat; + cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue)); + cell.CellStyle = styles.DataDate; } - private static string Letter(int column) => XLHelper.GetColumnLetterFromNumber(column); + // ==================== Лист 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); + } + } } diff --git a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index feeede5..2451dac 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -4,8 +4,8 @@ - - + + diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs index 753c13a..1b6bd2c 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs @@ -1,14 +1,16 @@ -using ClosedXML.Excel; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; using Shouldly; using Tnsu.ApprovalRoute.Application.Reports; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// -/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители -/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл -/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется -/// именно то, что увидит Excel. +/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок, +/// строки-разделители по объектам, двойная нумерация (сквозная + внутри +/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё +/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается +/// обратно тем же NPOI — проверяется именно то, что увидит Excel. /// public sealed class VacancyReportWorkbookTests { @@ -33,122 +35,147 @@ public sealed class VacancyReportWorkbookTests closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes, recruiter); - private static IXLWorksheet Build(params VacancyReportGroup[] groups) + private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups) { - var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups); + var report = new VacancyReport(from, to, groups); var bytes = VacancyReportWorkbook.Build(report); + return new XSSFWorkbook(new MemoryStream(bytes)); + } - using var stream = new MemoryStream(bytes); - // Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML - // держит уже разобранную модель в памяти, а не ленивую ссылку на поток. - var workbook = new XLWorkbook(stream); - return workbook.Worksheet("Список вакансий (все)"); + private static ISheet BuildListSheet(params VacancyReportGroup[] groups) => + BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups) + .GetSheet("Список вакансий (все)"); + + /// Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E». + private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) => + sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1); + + private static string StringAt(ISheet sheet, int excelRow, int excelCol) => + CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? ""; + + private static int IntAt(ISheet sheet, int excelRow, int excelCol) => + (int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста")); + + private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) => + CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value; + + private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol) + { + var cell = CellAt(sheet, excelRow, excelCol); + return cell is null || cell.CellType == CellType.Blank; + } + + /// Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе). + private static string FormulaAt(ISheet sheet, int excelRow, int excelCol) + { + var cell = CellAt(sheet, excelRow, excelCol); + return cell?.CellType == CellType.Formula ? cell.CellFormula : ""; } [Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")] public void Header_MatchesSample() { - var sheet = Build(new VacancyReportGroup("", [Row("Логист")])); + var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); - sheet.Cell(1, 2).GetString().ShouldBe("№"); - sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); - sheet.Cell(1, 4).GetString().ShouldBe("Подразделение"); - sheet.Cell(1, 5).GetString().ShouldBe("Кол-во вакансий"); - sheet.Cell(1, 8).GetString().ShouldBe("Дата подачи заявки"); - sheet.Cell(1, 9).GetString().ShouldBe("Дата закрытия вакансии"); - sheet.Cell(1, 11).GetString().ShouldBe("Дата выхода"); - sheet.Cell(1, 12).GetString().ShouldBe("Инициатор заявки"); - sheet.Cell(1, 14).GetString().ShouldBe("Комментарии"); - sheet.Cell(1, 16).GetString().ShouldBe("Рекрутер"); - sheet.Cell(1, 17).IsEmpty().ShouldBeTrue(); + StringAt(sheet, 1, 2).ShouldBe("№"); + StringAt(sheet, 1, 3).ShouldBe("Вакансия"); + StringAt(sheet, 1, 4).ShouldBe("Подразделение"); + StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий"); + StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки"); + StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии"); + StringAt(sheet, 1, 11).ShouldBe("Дата выхода"); + StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки"); + StringAt(sheet, 1, 14).ShouldBe("Комментарии"); + StringAt(sheet, 1, 16).ShouldBe("Рекрутер"); + IsEmptyAt(sheet, 1, 17).ShouldBeTrue(); } [Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")] public void DataRow_Layout() { - var sheet = Build(new VacancyReportGroup("", [ + var sheet = BuildListSheet(new VacancyReportGroup("", [ Row("Зам нач.юр.отдела", vacancies: 1, closed: 1, submitDate: new DateOnly(2026, 3, 30), closingDate: new DateOnly(2026, 7, 8), startDate: new DateOnly(2026, 8, 17)) ])); - sheet.Cell(2, 1).GetValue().ShouldBe(1); - sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела"); - sheet.Cell(2, 5).GetValue().ShouldBe(1); - sheet.Cell(2, 6).GetValue().ShouldBe(1); - sheet.Cell(2, 8).GetDateTime().ShouldBe(new DateTime(2026, 3, 30)); - sheet.Cell(2, 9).GetDateTime().ShouldBe(new DateTime(2026, 7, 8)); - sheet.Cell(2, 11).GetDateTime().ShouldBe(new DateTime(2026, 8, 17)); - sheet.Cell(2, 12).GetString().ShouldBe("Ким Н.Ю."); + IntAt(sheet, 2, 1).ShouldBe(1); + StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела"); + IntAt(sheet, 2, 5).ShouldBe(1); + IntAt(sheet, 2, 6).ShouldBe(1); + DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30)); + DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8)); + DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17)); + StringAt(sheet, 2, 12).ShouldBe("Ким Н.Ю."); // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. - sheet.Cell(2, 14).IsEmpty().ShouldBeTrue(); - sheet.Cell(2, 15).GetString().ShouldBe("да"); - sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна"); + IsEmptyAt(sheet, 2, 14).ShouldBeTrue(); + StringAt(sheet, 2, 15).ShouldBe("да"); + StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна"); } [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")] public void DerivedColumns_AreFormulas() { - var sheet = Build(new VacancyReportGroup("", [ + var sheet = BuildListSheet(new VacancyReportGroup("", [ Row("Логист", vacancies: 3, closed: 1, submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15)) ])); - sheet.Cell(2, 7).FormulaA1.ShouldBe("E2-F2"); - sheet.Cell(2, 10).FormulaA1.ShouldBe("I2-H2"); + FormulaAt(sheet, 2, 7).ShouldBe("E2-F2"); + FormulaAt(sheet, 2, 10).ShouldBe("I2-H2"); } [Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")] public void LeadTime_EmptyWithoutClosingDate() { - var sheet = Build(new VacancyReportGroup("", [Row("Логист")])); + var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); - sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty(); - sheet.Cell(2, 10).IsEmpty().ShouldBeTrue(); + FormulaAt(sheet, 2, 10).ShouldBeEmpty(); + IsEmptyAt(sheet, 2, 10).ShouldBeTrue(); } [Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")] public void GroupSeparator_RestartsNumbering() { - var sheet = Build( + var sheet = BuildListSheet( new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")])); // Верхний блок без объекта — сразу после шапки, без разделителя. - sheet.Cell(2, 3).GetString().ShouldBe("Логист"); - sheet.Cell(3, 3).GetString().ShouldBe("Юрист"); + StringAt(sheet, 2, 3).ShouldBe("Логист"); + StringAt(sheet, 3, 3).ShouldBe("Юрист"); - sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ"); - sheet.Cell(4, 3).IsEmpty().ShouldBeTrue(); + StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ"); + IsEmptyAt(sheet, 4, 3).ShouldBeTrue(); // Сквозная нумерация продолжается, нумерация внутри объекта — с единицы. - sheet.Cell(5, 1).GetValue().ShouldBe(3); - sheet.Cell(5, 2).GetValue().ShouldBe(1); - sheet.Cell(6, 1).GetValue().ShouldBe(4); - sheet.Cell(6, 2).GetValue().ShouldBe(2); + IntAt(sheet, 5, 1).ShouldBe(3); + IntAt(sheet, 5, 2).ShouldBe(1); + IntAt(sheet, 6, 1).ShouldBe(4); + IntAt(sheet, 6, 2).ShouldBe(2); } [Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")] public void Totals_SumWholeDataRange() { - var sheet = Build( + var sheet = BuildListSheet( new VacancyReportGroup("", [Row("Логист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")])); // Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке. - sheet.Cell(5, 5).FormulaA1.ShouldBe("SUM(E2:E4)"); - sheet.Cell(5, 6).FormulaA1.ShouldBe("SUM(F2:F4)"); - sheet.Cell(5, 7).FormulaA1.ShouldBe("SUM(G2:G4)"); + FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)"); + FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)"); + FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)"); } [Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")] public void EmptyReport_HeaderOnly() { - var sheet = Build(); + var sheet = BuildListSheet(); - sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); - sheet.Cell(2, 5).IsEmpty().ShouldBeTrue(); + StringAt(sheet, 1, 3).ShouldBe("Вакансия"); + IsEmptyAt(sheet, 2, 5).ShouldBeTrue(); } [Fact(DisplayName = "Имя файла — с периодом отчёта")] @@ -158,4 +185,113 @@ public sealed class VacancyReportWorkbookTests VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx"); } + + // ==================== Лист «Результаты» ==================== + + private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) => + Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter); + + [Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")] + public void SummarySheet_HorizontalTable() + { + var workbook = BuildWorkbook( + new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), + new VacancyReportGroup("", [ + RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40% + RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100% + ])); + var sheet = workbook.GetSheet("Результаты"); + + StringAt(sheet, 1, 1).ShouldBe("результаты"); + + // Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5). + StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий"); + StringAt(sheet, 2, 2).ShouldBe("Надежда"); + StringAt(sheet, 2, 3).ShouldBe("Алтынай"); + + StringAt(sheet, 3, 1).ShouldBe("Всего"); + IntAt(sheet, 3, 2).ShouldBe(10); + IntAt(sheet, 3, 3).ShouldBe(5); + + StringAt(sheet, 4, 1).ShouldBe("в работе (в%)"); + CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001); + CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001); + + StringAt(sheet, 5, 1).ShouldBe("закрыто"); + CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001); + CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001); + } + + [Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")] + public void SummarySheet_UnassignedRecruiterLast() + { + var workbook = BuildWorkbook( + new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), + new VacancyReportGroup("", [ + RowFor("Алтынай", vacancies: 1, closed: 0), + Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null), + ])); + var sheet = workbook.GetSheet("Результаты"); + + // «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на + // то, что её объём (100) больше, чем у Алтынай (1): это не участник + // рейтинга, а технический хвост. + StringAt(sheet, 2, 2).ShouldBe("Алтынай"); + StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel); + } + + [Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")] + public void SummarySheet_VerticalTables() + { + var workbook = BuildWorkbook( + new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), + new VacancyReportGroup("", [ + RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе + RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт + ])); + var sheet = workbook.GetSheet("Результаты"); + + // Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк + // горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%. + StringAt(sheet, 7, 1).ShouldBe("Общий объем работ"); + IntAt(sheet, 7, 2).ShouldBe(4); + StringAt(sheet, 8, 1).ShouldBe("Надежда"); + CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001); + StringAt(sheet, 9, 1).ShouldBe("Алтынай"); + CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001); + + // Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда + // не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%. + StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе"); + IntAt(sheet, 11, 2).ShouldBe(3); + StringAt(sheet, 12, 1).ShouldBe("Надежда"); + CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001); + StringAt(sheet, 13, 1).ShouldBe("Алтынай"); + CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001); + } + + [Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")] + public void SummarySheet_HasTwoPieCharts() + { + var workbook = BuildWorkbook( + new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), + new VacancyReportGroup("", [ + RowFor("Надежда", vacancies: 3, closed: 1), + RowFor("Алтынай", vacancies: 2, closed: 0), + ])); + var sheet = (XSSFSheet)workbook.GetSheet("Результаты"); + + var charts = sheet.GetDrawingPatriarch()?.GetCharts(); + charts.ShouldNotBeNull(); + charts!.Count.ShouldBe(2); + } + + [Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")] + public void SummarySheet_EmptyReport_NoTablesOrCharts() + { + var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31)); + var sheet = (XSSFSheet)workbook.GetSheet("Результаты"); + + sheet.GetDrawingPatriarch().ShouldBeNull(); + } }