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
@@ -227,37 +227,59 @@ public sealed class VacancyReportService(
? found
: [];
// Закрытыми считаем места, на которые реально оформили приём: отклонённая
// заявка на приём вакансию не закрывает — кандидат по ней не выходит.
var closed = hires.Count(x => x.Status != HireRequestStatus.Rejected);
// Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
// кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
// ложью («вот кого приняли»).
var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
return new VacancyReportRow(
row.Position ?? string.Empty,
row.DepartmentName,
row.VacanciesCount,
closed,
accepted.Count,
row.SubmitDate,
execution.ClosingDate,
hires.Select(x => x.HireDate).Where(x => x is not null).Max(),
ShortName(row.Initiator),
ReplacedEmployee(row, hires),
ClosedFlag(row.VacanciesCount, closed),
execution.Recruiter);
ClosedFlag(row.VacanciesCount, accepted.Count),
execution.Recruiter,
Lines(row, accepted));
}
/// <summary>
/// «Вместо сотрудника» — сначала из заявки на приём (там это отдельное поле
/// «Взамен кого»), иначе из уточнения к причине открытия вакансии, и только
/// если причина — замена уволенного: при «новой штатной единице» в том же
/// поле лежит совсем другой текст.
/// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
/// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
/// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
/// заполняется из причины открытия вакансии.
/// </summary>
private static string? ReplacedEmployee(SelectionRow row, IReadOnlyList<HireLinkRow> hires)
private static IReadOnlyList<VacancyReportLine> Lines(SelectionRow row, IReadOnlyList<HireLinkRow> accepted)
{
var fromHires = hires
.Select(x => x.ReplacedEmployee)
.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
if (accepted.Count == 0)
return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
if (fromHires is not null) return fromHires;
return accepted
// Порядок строк внутри вакансии — по дате выхода: так в отчёте видно,
// кто вышел раньше. Приём без даты уходит в конец.
.OrderBy(x => x.HireDate ?? DateOnly.MaxValue)
.ThenBy(x => x.CandidateName, StringComparer.CurrentCulture)
.Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x)))
.ToList();
}
/// <summary>
/// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле
/// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если
/// причина — замена уволенного: при «новой штатной единице» в том же поле
/// лежит совсем другой текст.
///
/// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии
/// он одинаковый — это нормально: причина открытия у вакансии одна.
///
/// hire = null — у вакансии приёмов нет вовсе, остаётся только откат.
/// </summary>
private static string? ReplacedEmployee(SelectionRow row, HireLinkRow? hire)
{
if (hire is not null && !string.IsNullOrWhiteSpace(hire.ReplacedEmployee))
return hire.ReplacedEmployee;
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
}