Files
tnsu-approval-route/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs
T
gorbenko.pavel 24d2cd7a6a feat: отчёт по вакансиям в xlsx за период
GET api/reports/vacancies?from=&to= отдаёт лист «Список вакансий (все)»
по образцу ручного отчёта HR: те же шестнадцать колонок, разделители по
объектам, двойная нумерация, итоги формулами.

Данные — из двух форм портала: «Заявка на подбор» даёт вакансию,
«Приём на работу» — её закрытие (связь по SelectionRequestId +
SelectionRequestListId). Рекрутёр и дата закрытия берутся из фазы
«Исполнение» парного списка «— согласование»: в самой заявке их нет.

Период отбирает заявки по «Дате подачи заявки» с откатом на Created —
поле заполняется руками и часто пустое. Заявки на приём периодом не
ограничены: приём по вакансии из начала интервала мог оформляться уже
после его конца.

Колонка «Комментарии» остаётся пустой — в ручном отчёте это пометки
рекрутёра, которым в заявках пока негде храниться.

Генерация — ClosedXML (MIT): нужны стили, формулы и autofilter, а EPPlus
с v5 под Polyform Noncommercial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:01:24 +05:00

162 lines
7.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClosedXML.Excel;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Reports;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители
/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл
/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется
/// именно то, что увидит 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 IXLWorksheet Build(params VacancyReportGroup[] groups)
{
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups);
var bytes = VacancyReportWorkbook.Build(report);
using var stream = new MemoryStream(bytes);
// Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML
// держит уже разобранную модель в памяти, а не ленивую ссылку на поток.
var workbook = new XLWorkbook(stream);
return workbook.Worksheet("Список вакансий (все)");
}
[Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")]
public void Header_MatchesSample()
{
var sheet = Build(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();
}
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
public void DataRow_Layout()
{
var sheet = Build(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("Ким Н.Ю.");
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
sheet.Cell(2, 14).IsEmpty().ShouldBeTrue();
sheet.Cell(2, 15).GetString().ShouldBe("да");
sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна");
}
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
public void DerivedColumns_AreFormulas()
{
var sheet = Build(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");
}
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
public void LeadTime_EmptyWithoutClosingDate()
{
var sheet = Build(new VacancyReportGroup("", [Row("Логист")]));
sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty();
sheet.Cell(2, 10).IsEmpty().ShouldBeTrue();
}
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
public void GroupSeparator_RestartsNumbering()
{
var sheet = Build(
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
// Верхний блок без объекта — сразу после шапки, без разделителя.
sheet.Cell(2, 3).GetString().ShouldBe("Логист");
sheet.Cell(3, 3).GetString().ShouldBe("Юрист");
sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ");
sheet.Cell(4, 3).IsEmpty().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);
}
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
public void Totals_SumWholeDataRange()
{
var sheet = Build(
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)");
}
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
public void EmptyReport_HeaderOnly()
{
var sheet = Build();
sheet.Cell(1, 3).GetString().ShouldBe("Вакансия");
sheet.Cell(2, 5).IsEmpty().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");
}
}