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>
This commit is contained in:
+161
@@ -0,0 +1,161 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user