feat(отчёт): строка на каждый приём + столбец «Ф.И.О. нанимаемого сотрудника»
Tests / frontend-test (pull_request) Successful in 53s
Tests / backend-test (pull_request) Successful in 5m34s

На одну заявку на подбор приходится несколько заявок на приём («Разнорабочий,
4 вакансии» → 4 кандидата), и раньше они схлопывались в одну строку: ФИО не
было вовсе, а «Дата выхода» показывала только САМУЮ ПОЗДНЮЮ из четырёх. Кто
именно вышел и когда — из отчёта было не узнать.

Теперь вакансия занимает столько строк, сколько по ней приёмов. Построчные
колонки — «Дата выхода», новая «Ф.И.О. нанимаемого сотрудника» и «Вместо
сотрудника Ф.И.О.» (у каждого приёма своё «взамен кого»); все остальные общие
и объединяются по вертикали.

Модель: VacancyReportRow.Lines — именно СТРОКИ ЛИСТА, а не «приёмы». У вакансии
без приёмов строка всё равно одна (иначе она пропала бы из отчёта), с пустыми
ФИО и датой, но с «Вместо сотрудника» из причины открытия вакансии — прежнее
поведение сохранено. Реальное число приёмов осталось в ClosedCount.

Отклонённые приёмы теперь отбрасываются целиком, а не только из счётчика:
отдельная строка с ФИО отклонённого кандидата утверждала бы «вот кого приняли».

Объединение заводится только при lineCount > 1 — область 1×1 Excel считает
некорректной и портит файл. Значение общих колонок пишется ТОЛЬКО в первую
строку: у объединённой области Excel хранит его в верхней левой ячейке, а дубли
сломали бы SUM в итогах (посчитал бы вакансию по разу на каждый приём) — на это
есть отдельный тест.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-31 14:29:13 +05:00
parent 4cd5ccd97c
commit 240e5984b1
5 changed files with 302 additions and 88 deletions
@@ -107,14 +107,15 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
string selectionListUrl,
string status = "Закрыта",
string? hireDate = "2026-08-17T00:00:00Z",
string? replaced = null) =>
string? replaced = null,
string? candidate = "Шакенов Анель") =>
new
{
Id = selectionId * 10,
RequestStatus = status,
SelectionRequestId = (double)selectionId,
SelectionRequestListId = selectionListUrl,
CandidateName = "Шакенов Анель",
CandidateName = candidate,
ReplacedEmployee = replaced,
HireDate = hireDate
};
@@ -195,9 +196,11 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8));
row.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
row.Initiator.ShouldBe("Ким Н.Ю.");
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
var line = row.Lines.ShouldHaveSingleItem();
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
line.CandidateName.ShouldBe("Шакенов Анель");
}
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
@@ -214,7 +217,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(0);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
row.StartDate.ShouldBeNull();
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
}
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
@@ -271,8 +275,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
}
[Fact(DisplayName = "«Дата выхода» — самая поздняя среди нескольких приёмов")]
public async Task StartDate_IsLatestHireDate()
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
public async Task SeveralHires_OneLineEach()
{
const string name = "several";
var list = SelectionListUrl(name);
@@ -281,15 +285,40 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
[Selection(1, "Разнорабочий", vacancies: 2)],
[Execution(1)],
[
Hire(1, list, hireDate: "2026-08-05T00:00:00Z"),
Hire(1, list, hireDate: "2026-08-25T00:00:00Z")
// Намеренно НЕ в порядке дат — проверяем сортировку строк.
Hire(1, list, hireDate: "2026-08-25T00:00:00Z", candidate: "Бутиев Азамат"),
Hire(1, list, hireDate: "2026-08-05T00:00:00Z", candidate: "Дулин Евгений")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(2);
row.StartDate.ShouldBe(new DateOnly(2026, 8, 25));
// Вакансия одна, но строк на листе две — по приёму на каждую.
row.Lines.Count.ShouldBe(2);
row.Lines.Select(l => l.StartDate).ShouldBe([new DateOnly(2026, 8, 5), new DateOnly(2026, 8, 25)]);
row.Lines.Select(l => l.CandidateName).ShouldBe(["Дулин Евгений", "Бутиев Азамат"]);
}
[Fact(DisplayName = "Отклонённый приём не даёт строки — кандидата в отчёте быть не должно")]
public async Task RejectedHire_ProducesNoLine()
{
const string name = "rejectedLine";
var list = SelectionListUrl(name);
var (service, _) = await BuildAsync(
name,
[Selection(1, "Разнорабочий", vacancies: 2)],
[Execution(1)],
[
Hire(1, list, candidate: "Принятый Кандидат"),
Hire(1, list, status: "Отклонено", candidate: "Отклонённый Кандидат")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(1);
row.Lines.ShouldHaveSingleItem().CandidateName.ShouldBe("Принятый Кандидат");
}
[Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
@@ -344,9 +373,28 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
var result = await service.GetAsync(From, To, CancellationToken.None);
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
rows.Single(r => r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик");
rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull();
rows.Single(r => r.Position == "Начальник геодезии")
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Серик");
rows.Single(r => r.Position == "Инженер ВиВ")
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBeNull();
}
[Fact(DisplayName = "«Взамен кого» из заявки на приём важнее причины открытия вакансии")]
public async Task ReplacedEmployee_HireWins()
{
const string name = "replacedHire";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Юрист", openReason: "Замена уволенного сотрудника", openReasonDetails: "Из заявки")],
[Execution(1)],
[Hire(1, SelectionListUrl(name), replaced: "Из приёма")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
SingleRow(result.Value).Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Из приёма");
}
[Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")]