feat(отчёт): строка на каждый приём + столбец «Ф.И.О. нанимаемого сотрудника»
На одну заявку на подбор приходится несколько заявок на приём («Разнорабочий, 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:
+59
-11
@@ -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 = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||||
|
||||
Reference in New Issue
Block a user