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:
@@ -14,33 +14,54 @@ public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList<Vac
|
||||
public sealed record VacancyReportGroup(string Name, IReadOnlyList<VacancyReportRow> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Одна вакансия — строка листа. Колонки A/B (нумерация), G (в работе), J (срок
|
||||
/// отработки) и итоги здесь не хранятся: их считает сам xlsx формулами, чтобы
|
||||
/// цифры пересчитывались, если строку в файле поправят руками.
|
||||
/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк
|
||||
/// на листе, там они объединяются по вертикали; то, что различается от приёма к
|
||||
/// приёму, лежит в Lines.
|
||||
///
|
||||
/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся:
|
||||
/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в
|
||||
/// файле поправят руками.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportRow(
|
||||
// C — «Вакансия»: должность из заявки на подбор.
|
||||
// «Вакансия» — должность из заявки на подбор.
|
||||
string Position,
|
||||
// D — «Подразделение» ответственного за подачу заявки.
|
||||
// «Подразделение» ответственного за подачу заявки.
|
||||
string? Department,
|
||||
// E — «Кол-во вакансий».
|
||||
// «Кол-во вакансий».
|
||||
int VacanciesCount,
|
||||
// F — «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
||||
// «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
||||
int ClosedCount,
|
||||
// H — «Дата подачи заявки».
|
||||
// «Дата подачи заявки».
|
||||
DateOnly? SubmitDate,
|
||||
// I — «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
||||
// «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
||||
DateOnly? ClosingDate,
|
||||
// K — «Дата выхода»: самая поздняя дата приёма среди принятых по этой вакансии.
|
||||
DateOnly? StartDate,
|
||||
// L — «Инициатор заявки».
|
||||
// «Инициатор заявки».
|
||||
string? Initiator,
|
||||
// M — «Вместо сотрудника Ф.И.О.».
|
||||
string? ReplacedEmployee,
|
||||
// O — «Закрыта вакансия»: да / нет / частично.
|
||||
// «Закрыта вакансия»: да / нет / частично.
|
||||
string ClosedFlag,
|
||||
// P — «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
|
||||
string? Recruiter);
|
||||
// «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
|
||||
string? Recruiter,
|
||||
// Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine).
|
||||
IReadOnlyList<VacancyReportLine> Lines);
|
||||
|
||||
/// <summary>
|
||||
/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками.
|
||||
/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии»
|
||||
/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и
|
||||
/// свой «взамен кого».
|
||||
///
|
||||
/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все
|
||||
/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с
|
||||
/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число
|
||||
/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportLine(
|
||||
// «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём.
|
||||
string? CandidateName,
|
||||
// «Дата выхода» — дата приёма этого кандидата.
|
||||
DateOnly? StartDate,
|
||||
// «Вместо сотрудника Ф.И.О.».
|
||||
string? ReplacedEmployee);
|
||||
|
||||
/// <summary>Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.</summary>
|
||||
public static class VacancyClosedFlags
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user