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);
|
public sealed record VacancyReportGroup(string Name, IReadOnlyList<VacancyReportRow> Rows);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Одна вакансия — строка листа. Колонки A/B (нумерация), G (в работе), J (срок
|
/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк
|
||||||
/// отработки) и итоги здесь не хранятся: их считает сам xlsx формулами, чтобы
|
/// на листе, там они объединяются по вертикали; то, что различается от приёма к
|
||||||
/// цифры пересчитывались, если строку в файле поправят руками.
|
/// приёму, лежит в Lines.
|
||||||
|
///
|
||||||
|
/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся:
|
||||||
|
/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в
|
||||||
|
/// файле поправят руками.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record VacancyReportRow(
|
public sealed record VacancyReportRow(
|
||||||
// C — «Вакансия»: должность из заявки на подбор.
|
// «Вакансия» — должность из заявки на подбор.
|
||||||
string Position,
|
string Position,
|
||||||
// D — «Подразделение» ответственного за подачу заявки.
|
// «Подразделение» ответственного за подачу заявки.
|
||||||
string? Department,
|
string? Department,
|
||||||
// E — «Кол-во вакансий».
|
// «Кол-во вакансий».
|
||||||
int VacanciesCount,
|
int VacanciesCount,
|
||||||
// F — «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
// «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
||||||
int ClosedCount,
|
int ClosedCount,
|
||||||
// H — «Дата подачи заявки».
|
// «Дата подачи заявки».
|
||||||
DateOnly? SubmitDate,
|
DateOnly? SubmitDate,
|
||||||
// I — «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
// «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
||||||
DateOnly? ClosingDate,
|
DateOnly? ClosingDate,
|
||||||
// K — «Дата выхода»: самая поздняя дата приёма среди принятых по этой вакансии.
|
// «Инициатор заявки».
|
||||||
DateOnly? StartDate,
|
|
||||||
// L — «Инициатор заявки».
|
|
||||||
string? Initiator,
|
string? Initiator,
|
||||||
// M — «Вместо сотрудника Ф.И.О.».
|
// «Закрыта вакансия»: да / нет / частично.
|
||||||
string? ReplacedEmployee,
|
|
||||||
// O — «Закрыта вакансия»: да / нет / частично.
|
|
||||||
string ClosedFlag,
|
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>
|
/// <summary>Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.</summary>
|
||||||
public static class VacancyClosedFlags
|
public static class VacancyClosedFlags
|
||||||
|
|||||||
@@ -227,37 +227,59 @@ public sealed class VacancyReportService(
|
|||||||
? found
|
? found
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Закрытыми считаем места, на которые реально оформили приём: отклонённая
|
// Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
|
||||||
// заявка на приём вакансию не закрывает — кандидат по ней не выходит.
|
// кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
|
||||||
var closed = hires.Count(x => x.Status != HireRequestStatus.Rejected);
|
// ложью («вот кого приняли»).
|
||||||
|
var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
|
||||||
|
|
||||||
return new VacancyReportRow(
|
return new VacancyReportRow(
|
||||||
row.Position ?? string.Empty,
|
row.Position ?? string.Empty,
|
||||||
row.DepartmentName,
|
row.DepartmentName,
|
||||||
row.VacanciesCount,
|
row.VacanciesCount,
|
||||||
closed,
|
accepted.Count,
|
||||||
row.SubmitDate,
|
row.SubmitDate,
|
||||||
execution.ClosingDate,
|
execution.ClosingDate,
|
||||||
hires.Select(x => x.HireDate).Where(x => x is not null).Max(),
|
|
||||||
ShortName(row.Initiator),
|
ShortName(row.Initiator),
|
||||||
ReplacedEmployee(row, hires),
|
ClosedFlag(row.VacanciesCount, accepted.Count),
|
||||||
ClosedFlag(row.VacanciesCount, closed),
|
execution.Recruiter,
|
||||||
execution.Recruiter);
|
Lines(row, accepted));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// «Вместо сотрудника» — сначала из заявки на приём (там это отдельное поле
|
/// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
|
||||||
/// «Взамен кого»), иначе из уточнения к причине открытия вакансии, и только
|
/// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
|
||||||
/// если причина — замена уволенного: при «новой штатной единице» в том же
|
/// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
|
||||||
/// поле лежит совсем другой текст.
|
/// заполняется из причины открытия вакансии.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? ReplacedEmployee(SelectionRow row, IReadOnlyList<HireLinkRow> hires)
|
private static IReadOnlyList<VacancyReportLine> Lines(SelectionRow row, IReadOnlyList<HireLinkRow> accepted)
|
||||||
{
|
{
|
||||||
var fromHires = hires
|
if (accepted.Count == 0)
|
||||||
.Select(x => x.ReplacedEmployee)
|
return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
|
||||||
.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
|
|
||||||
|
|
||||||
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;
|
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public static class VacancyReportWorkbook
|
|||||||
("Дата закрытия вакансии", 14.9),
|
("Дата закрытия вакансии", 14.9),
|
||||||
("Срок отработки", 12.3),
|
("Срок отработки", 12.3),
|
||||||
("Дата выхода", 13.9),
|
("Дата выхода", 13.9),
|
||||||
|
("Ф.И.О. нанимаемого сотрудника", 30.0),
|
||||||
("Инициатор заявки", 26.9),
|
("Инициатор заявки", 26.9),
|
||||||
("Вместо сотрудника Ф.И.О.", 26.0),
|
("Вместо сотрудника Ф.И.О.", 26.0),
|
||||||
("Комментарии", 51.3),
|
("Комментарии", 51.3),
|
||||||
@@ -66,10 +67,19 @@ public static class VacancyReportWorkbook
|
|||||||
private const int ColClosingDate = 8;
|
private const int ColClosingDate = 8;
|
||||||
private const int ColLeadTime = 9;
|
private const int ColLeadTime = 9;
|
||||||
private const int ColStartDate = 10;
|
private const int ColStartDate = 10;
|
||||||
private const int ColInitiator = 11;
|
private const int ColCandidateName = 11;
|
||||||
private const int ColReplaced = 12;
|
private const int ColInitiator = 12;
|
||||||
private const int ColClosedFlag = 14;
|
private const int ColReplaced = 13;
|
||||||
private const int ColRecruiter = 15;
|
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;
|
private const int HeaderRowIdx = 0;
|
||||||
|
|
||||||
@@ -108,7 +118,7 @@ public static class VacancyReportWorkbook
|
|||||||
|
|
||||||
var groupNumber = 0;
|
var groupNumber = 0;
|
||||||
foreach (var item in group.Rows)
|
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;
|
var lastDataRowIdx = rowIdx - 1;
|
||||||
@@ -151,46 +161,87 @@ public static class VacancyReportWorkbook
|
|||||||
row.GetCell(ColGroupNumber).SetCellValue(name);
|
row.GetCell(ColGroupNumber).SetCellValue(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteRow(
|
/// <summary>
|
||||||
XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
|
/// Одна вакансия — столько строк, сколько у неё 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 lines = item.Lines;
|
||||||
var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки.
|
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);
|
var first = sheet.GetRow(firstRowIdx);
|
||||||
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}");
|
|
||||||
|
|
||||||
SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate);
|
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
|
||||||
SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate);
|
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 тысяч
|
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
|
||||||
// дней (пустая ячейка — это 00.01.1900).
|
// дней (пустая ячейка — это 00.01.1900).
|
||||||
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
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.CellStyle = styles.DataLeadTime;
|
||||||
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
||||||
}
|
}
|
||||||
|
|
||||||
SetDate(row.GetCell(ColStartDate), styles, item.StartDate);
|
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
||||||
|
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
|
||||||
row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
|
||||||
row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty);
|
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
||||||
// Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в
|
|
||||||
// ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым
|
|
||||||
// в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
|
||||||
// в выгрузке.
|
// в выгрузке.
|
||||||
row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
||||||
row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
|
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)
|
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
|
||||||
|
|||||||
+59
-11
@@ -107,14 +107,15 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
string selectionListUrl,
|
string selectionListUrl,
|
||||||
string status = "Закрыта",
|
string status = "Закрыта",
|
||||||
string? hireDate = "2026-08-17T00:00:00Z",
|
string? hireDate = "2026-08-17T00:00:00Z",
|
||||||
string? replaced = null) =>
|
string? replaced = null,
|
||||||
|
string? candidate = "Шакенов Анель") =>
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = selectionId * 10,
|
Id = selectionId * 10,
|
||||||
RequestStatus = status,
|
RequestStatus = status,
|
||||||
SelectionRequestId = (double)selectionId,
|
SelectionRequestId = (double)selectionId,
|
||||||
SelectionRequestListId = selectionListUrl,
|
SelectionRequestListId = selectionListUrl,
|
||||||
CandidateName = "Шакенов Анель",
|
CandidateName = candidate,
|
||||||
ReplacedEmployee = replaced,
|
ReplacedEmployee = replaced,
|
||||||
HireDate = hireDate
|
HireDate = hireDate
|
||||||
};
|
};
|
||||||
@@ -195,9 +196,11 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
|
||||||
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
|
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||||||
row.ClosingDate.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.Initiator.ShouldBe("Ким Н.Ю.");
|
||||||
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
||||||
|
var line = row.Lines.ShouldHaveSingleItem();
|
||||||
|
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
|
||||||
|
line.CandidateName.ShouldBe("Шакенов Анель");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
||||||
@@ -214,7 +217,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var row = SingleRow(result.Value);
|
var row = SingleRow(result.Value);
|
||||||
row.ClosedCount.ShouldBe(0);
|
row.ClosedCount.ShouldBe(0);
|
||||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||||
row.StartDate.ShouldBeNull();
|
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
|
||||||
|
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
||||||
@@ -271,8 +275,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "«Дата выхода» — самая поздняя среди нескольких приёмов")]
|
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
|
||||||
public async Task StartDate_IsLatestHireDate()
|
public async Task SeveralHires_OneLineEach()
|
||||||
{
|
{
|
||||||
const string name = "several";
|
const string name = "several";
|
||||||
var list = SelectionListUrl(name);
|
var list = SelectionListUrl(name);
|
||||||
@@ -281,15 +285,40 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
[Selection(1, "Разнорабочий", vacancies: 2)],
|
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||||
[Execution(1)],
|
[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 result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
var row = SingleRow(result.Value);
|
var row = SingleRow(result.Value);
|
||||||
row.ClosedCount.ShouldBe(2);
|
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 = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
[Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
||||||
@@ -344,9 +373,28 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
|
|
||||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
|
||||||
|
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
|
||||||
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
||||||
rows.Single(r => r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик");
|
rows.Single(r => r.Position == "Начальник геодезии")
|
||||||
rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull();
|
.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 = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
[Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||||||
|
|||||||
+85
-13
@@ -21,7 +21,8 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
DateOnly? submitDate = null,
|
DateOnly? submitDate = null,
|
||||||
DateOnly? closingDate = null,
|
DateOnly? closingDate = null,
|
||||||
DateOnly? startDate = null,
|
DateOnly? startDate = null,
|
||||||
string? recruiter = "Сергеева Надежда Ивановна") =>
|
string? recruiter = "Сергеева Надежда Ивановна",
|
||||||
|
IReadOnlyList<VacancyReportLine>? lines = null) =>
|
||||||
new(
|
new(
|
||||||
position,
|
position,
|
||||||
"офис",
|
"офис",
|
||||||
@@ -29,11 +30,16 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
closed,
|
closed,
|
||||||
submitDate ?? new DateOnly(2026, 7, 8),
|
submitDate ?? new DateOnly(2026, 7, 8),
|
||||||
closingDate,
|
closingDate,
|
||||||
startDate,
|
|
||||||
"Ким Н.Ю.",
|
"Ким Н.Ю.",
|
||||||
null,
|
|
||||||
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||||||
recruiter);
|
recruiter,
|
||||||
|
// По умолчанию одна строка — вакансия без приёмов либо с одним;
|
||||||
|
// тесты многострочности передают lines явно.
|
||||||
|
lines ?? [new VacancyReportLine(null, startDate, null)]);
|
||||||
|
|
||||||
|
/// <summary>Строка листа в пределах вакансии — то, что различается между приёмами.</summary>
|
||||||
|
private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) =>
|
||||||
|
new(candidate, startDate, replaced);
|
||||||
|
|
||||||
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
|
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
|
||||||
{
|
{
|
||||||
@@ -72,7 +78,7 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
|
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")]
|
[Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")]
|
||||||
public void Header_MatchesSample()
|
public void Header_MatchesSample()
|
||||||
{
|
{
|
||||||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
@@ -84,10 +90,11 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
|
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
|
||||||
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
|
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
|
||||||
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
|
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
|
||||||
StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки");
|
StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника");
|
||||||
StringAt(sheet, 1, 14).ShouldBe("Комментарии");
|
StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки");
|
||||||
StringAt(sheet, 1, 16).ShouldBe("Рекрутер");
|
StringAt(sheet, 1, 15).ShouldBe("Комментарии");
|
||||||
IsEmptyAt(sheet, 1, 17).ShouldBeTrue();
|
StringAt(sheet, 1, 17).ShouldBe("Рекрутер");
|
||||||
|
IsEmptyAt(sheet, 1, 18).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
|
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
|
||||||
@@ -124,11 +131,76 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
|
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
|
||||||
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
|
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
|
||||||
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17));
|
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17));
|
||||||
StringAt(sheet, 2, 12).ShouldBe("Ким Н.Ю.");
|
StringAt(sheet, 2, 13).ShouldBe("Ким Н.Ю.");
|
||||||
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||||||
IsEmptyAt(sheet, 2, 14).ShouldBeTrue();
|
IsEmptyAt(sheet, 2, 15).ShouldBeTrue();
|
||||||
StringAt(sheet, 2, 15).ShouldBe("да");
|
StringAt(sheet, 2, 16).ShouldBe("да");
|
||||||
StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна");
|
StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")]
|
||||||
|
public void SeveralHires_OneRowEachWithMergedCommonCells()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
|
Row("Разнорабочий", vacancies: 4, closed: 4,
|
||||||
|
submitDate: new DateOnly(2026, 7, 10),
|
||||||
|
lines: [
|
||||||
|
Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)),
|
||||||
|
Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)),
|
||||||
|
Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.")
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Построчные колонки — своё значение в каждой строке.
|
||||||
|
StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович");
|
||||||
|
StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич");
|
||||||
|
StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович");
|
||||||
|
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19));
|
||||||
|
DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21));
|
||||||
|
DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25));
|
||||||
|
StringAt(sheet, 4, 14).ShouldBe("Оспанов К.");
|
||||||
|
|
||||||
|
// Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel
|
||||||
|
// хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах.
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe("Разнорабочий");
|
||||||
|
IsEmptyAt(sheet, 3, 3).ShouldBeTrue();
|
||||||
|
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||||||
|
IntAt(sheet, 2, 5).ShouldBe(4);
|
||||||
|
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||||||
|
|
||||||
|
// И сами объединения заведены — иначе продолжения выглядели бы пустыми.
|
||||||
|
var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList();
|
||||||
|
merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2);
|
||||||
|
// Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы.
|
||||||
|
merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName
|
||||||
|
merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")]
|
||||||
|
public void SingleLine_NoMergedRegions()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
sheet.NumMergedRegions.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")]
|
||||||
|
public void Totals_CountVacancyOnce_NotPerHire()
|
||||||
|
{
|
||||||
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
|
Row("Разнорабочий", vacancies: 4, closed: 4, lines: [
|
||||||
|
Line("Первый", new DateOnly(2026, 8, 19)),
|
||||||
|
Line("Второй", new DateOnly(2026, 8, 21))
|
||||||
|
]),
|
||||||
|
Row("Логист", vacancies: 1, closed: 0)
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й.
|
||||||
|
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||||||
|
// Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение),
|
||||||
|
// поэтому SUM даёт 4+1=5, а не 4+4+1.
|
||||||
|
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||||||
|
IntAt(sheet, 4, 5).ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||||||
|
|||||||
Reference in New Issue
Block a user