4dac12302c
Второй лист 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>
298 lines
15 KiB
C#
298 lines
15 KiB
C#
using NPOI.SS.UserModel;
|
||
using NPOI.XSSF.UserModel;
|
||
using Shouldly;
|
||
using Tnsu.ApprovalRoute.Application.Reports;
|
||
|
||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||
|
||
/// <summary>
|
||
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
|
||
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
|
||
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
|
||
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
|
||
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
|
||
/// </summary>
|
||
public sealed class VacancyReportWorkbookTests
|
||
{
|
||
private static VacancyReportRow Row(
|
||
string position,
|
||
int vacancies = 1,
|
||
int closed = 0,
|
||
DateOnly? submitDate = null,
|
||
DateOnly? closingDate = null,
|
||
DateOnly? startDate = null,
|
||
string? recruiter = "Сергеева Надежда Ивановна") =>
|
||
new(
|
||
position,
|
||
"офис",
|
||
vacancies,
|
||
closed,
|
||
submitDate ?? new DateOnly(2026, 7, 8),
|
||
closingDate,
|
||
startDate,
|
||
"Ким Н.Ю.",
|
||
null,
|
||
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||
recruiter);
|
||
|
||
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
|
||
{
|
||
var report = new VacancyReport(from, to, groups);
|
||
var bytes = VacancyReportWorkbook.Build(report);
|
||
return new XSSFWorkbook(new MemoryStream(bytes));
|
||
}
|
||
|
||
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 = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||
|
||
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 = 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))
|
||
]));
|
||
|
||
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("Ким Н.Ю.");
|
||
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||
IsEmptyAt(sheet, 2, 14).ShouldBeTrue();
|
||
StringAt(sheet, 2, 15).ShouldBe("да");
|
||
StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна");
|
||
}
|
||
|
||
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||
public void DerivedColumns_AreFormulas()
|
||
{
|
||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||
Row("Логист", vacancies: 3, closed: 1,
|
||
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
|
||
]));
|
||
|
||
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 = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||
|
||
FormulaAt(sheet, 2, 10).ShouldBeEmpty();
|
||
IsEmptyAt(sheet, 2, 10).ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
|
||
public void GroupSeparator_RestartsNumbering()
|
||
{
|
||
var sheet = BuildListSheet(
|
||
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
|
||
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
|
||
|
||
// Верхний блок без объекта — сразу после шапки, без разделителя.
|
||
StringAt(sheet, 2, 3).ShouldBe("Логист");
|
||
StringAt(sheet, 3, 3).ShouldBe("Юрист");
|
||
|
||
StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ");
|
||
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||
|
||
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
|
||
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 = BuildListSheet(
|
||
new VacancyReportGroup("", [Row("Логист")]),
|
||
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
|
||
|
||
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
|
||
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 = BuildListSheet();
|
||
|
||
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||
IsEmptyAt(sheet, 2, 5).ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
|
||
public void FileName_CarriesPeriod()
|
||
{
|
||
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []);
|
||
|
||
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();
|
||
}
|
||
}
|