Отчет по вакансиям #20

Merged
gorbenko.pavel merged 7 commits from report into main 2026-08-31 11:00:09 +00:00
5 changed files with 302 additions and 88 deletions
Showing only changes of commit 240e5984b1 - Show all commits
@@ -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 offset = 0; offset < lineCount; offset++)
{
var row = sheet.CreateRow(firstRowIdx + offset);
for (var col = 0; col < Columns.Length; col++) for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Data; 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)
@@ -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 = "Конец периода раньше начала — ошибка, а не пустой файл")]
@@ -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 = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]