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
@@ -47,6 +47,7 @@ public static class VacancyReportWorkbook
("Дата закрытия вакансии", 14.9),
("Срок отработки", 12.3),
("Дата выхода", 13.9),
("Ф.И.О. нанимаемого сотрудника", 30.0),
("Инициатор заявки", 26.9),
("Вместо сотрудника Ф.И.О.", 26.0),
("Комментарии", 51.3),
@@ -66,10 +67,19 @@ public static class VacancyReportWorkbook
private const int ColClosingDate = 8;
private const int ColLeadTime = 9;
private const int ColStartDate = 10;
private const int ColInitiator = 11;
private const int ColReplaced = 12;
private const int ColClosedFlag = 14;
private const int ColRecruiter = 15;
private const int ColCandidateName = 11;
private const int ColInitiator = 12;
private const int ColReplaced = 13;
private const int ColComment = 14;
private const int ColClosedFlag = 15;
private const int ColRecruiter = 16;
/// <summary>
/// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
/// общие — при нескольких приёмах по одной заявке на подбор они объединяются
/// по вертикали (см. WriteVacancy).
/// </summary>
private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
private const int HeaderRowIdx = 0;
@@ -108,7 +118,7 @@ public static class VacancyReportWorkbook
var groupNumber = 0;
foreach (var item in group.Rows)
WriteRow(sheet, styles, rowIdx++, ++totalNumber, ++groupNumber, item);
rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item);
}
var lastDataRowIdx = rowIdx - 1;
@@ -151,46 +161,87 @@ public static class VacancyReportWorkbook
row.GetCell(ColGroupNumber).SetCellValue(name);
}
private static void WriteRow(
XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
/// <summary>
/// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
/// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
///
/// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
/// вертикали: у объединённой области Excel хранит значение в верхней левой
/// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
/// SUM в итогах посчитал бы одно и то же несколько раз).
/// </summary>
private static int WriteVacancy(
XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
{
var row = sheet.CreateRow(rowIdx);
var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки.
var lines = item.Lines;
var lineCount = Math.Max(lines.Count, 1);
var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки.
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Data;
// Все строки вакансии — со стилем данных, иначе у продолжений не будет
// границ и объединённая область выглядит рваной.
for (var offset = 0; offset < lineCount; offset++)
{
var row = sheet.CreateRow(firstRowIdx + offset);
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Data;
}
row.GetCell(ColTotalNumber).SetCellValue(totalNumber);
row.GetCell(ColGroupNumber).SetCellValue(groupNumber);
row.GetCell(ColPosition).SetCellValue(item.Position);
row.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
row.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
row.GetCell(ColClosed).SetCellValue(item.ClosedCount);
row.GetCell(ColInProgress).SetCellFormula($"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
var first = sheet.GetRow(firstRowIdx);
SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate);
SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate);
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
first.GetCell(ColPosition).SetCellValue(item.Position);
first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
first.GetCell(ColInProgress).SetCellFormula(
$"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
// «Срок отработки» — разница дат в днях, и только когда известны обе:
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
// дней (пустая ячейка — это 00.01.1900).
if (item.SubmitDate is not null && item.ClosingDate is not null)
{
var leadTimeCell = row.GetCell(ColLeadTime);
var leadTimeCell = first.GetCell(ColLeadTime);
leadTimeCell.CellStyle = styles.DataLeadTime;
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
}
SetDate(row.GetCell(ColStartDate), styles, item.StartDate);
row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty);
// Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в
// ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым
// в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
// в выгрузке.
row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
// Построчные поля — каждое в свою строку.
for (var offset = 0; offset < lines.Count; offset++)
{
var row = sheet.GetRow(firstRowIdx + offset);
var line = lines[offset];
row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
}
// Объединяем общие колонки только когда строк действительно больше одной:
// область 1×1 Excel считает некорректной и портит файл.
if (lineCount > 1)
{
var lastRowIdx = firstRowIdx + lineCount - 1;
for (var col = 0; col < Columns.Length; col++)
{
if (PerLineColumns.Contains(col)) continue;
sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
}
}
return lineCount;
}
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)