feat: сводный лист «Результаты» с диаграммами в отчёте по вакансиям
Второй лист xlsx по образцу ручного отчёта HR: горизонтальная таблица по рекрутёрам (всего/в работе %/закрыто %) и две вертикальные таблицы с долей каждого рекрутёра в общем объёме и в объёме "в работе" — плюс по круговой диаграмме на каждую (VacancyReportSummary, VacancyReportWorkbook.BuildSummarySheet). Библиотека для xlsx сменена с ClosedXML на NPOI: у ClosedXML нет поддержки Excel-диаграмм вовсе, только у NPOI из бесплатных вариантов она есть (XSSFChart/DataSources из NPOI.OOXML). Переписан весь генератор, тесты читают файл обратно тем же NPOI. Заодно запинена System.Security.Cryptography.Xml до 10.0.11 — версия, которую тянет NPOI транзитивно (8.0.2), с известной уязвимостью. Вакансии без рекрутёра сведены в «Без рекрутёра» и всегда идут в таблицах последними — не участник рейтинга, а технический хвост. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+194
-58
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители
|
||||
/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл
|
||||
/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется
|
||||
/// именно то, что увидит Excel.
|
||||
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
|
||||
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
|
||||
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
|
||||
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
|
||||
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
|
||||
/// </summary>
|
||||
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("Список вакансий (все)");
|
||||
|
||||
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
|
||||
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<int>().ShouldBe(1);
|
||||
sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела");
|
||||
sheet.Cell(2, 5).GetValue<int>().ShouldBe(1);
|
||||
sheet.Cell(2, 6).GetValue<int>().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<int>().ShouldBe(3);
|
||||
sheet.Cell(5, 2).GetValue<int>().ShouldBe(1);
|
||||
sheet.Cell(6, 1).GetValue<int>().ShouldBe(4);
|
||||
sheet.Cell(6, 2).GetValue<int>().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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user