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:
+59
-11
@@ -107,14 +107,15 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
string selectionListUrl,
|
||||
string status = "Закрыта",
|
||||
string? hireDate = "2026-08-17T00:00:00Z",
|
||||
string? replaced = null) =>
|
||||
string? replaced = null,
|
||||
string? candidate = "Шакенов Анель") =>
|
||||
new
|
||||
{
|
||||
Id = selectionId * 10,
|
||||
RequestStatus = status,
|
||||
SelectionRequestId = (double)selectionId,
|
||||
SelectionRequestListId = selectionListUrl,
|
||||
CandidateName = "Шакенов Анель",
|
||||
CandidateName = candidate,
|
||||
ReplacedEmployee = replaced,
|
||||
HireDate = hireDate
|
||||
};
|
||||
@@ -195,9 +196,11 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
|
||||
row.SubmitDate.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.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
||||
var line = row.Lines.ShouldHaveSingleItem();
|
||||
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
|
||||
line.CandidateName.ShouldBe("Шакенов Анель");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
||||
@@ -214,7 +217,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
var row = SingleRow(result.Value);
|
||||
row.ClosedCount.ShouldBe(0);
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||
row.StartDate.ShouldBeNull();
|
||||
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
|
||||
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
||||
@@ -271,8 +275,8 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "«Дата выхода» — самая поздняя среди нескольких приёмов")]
|
||||
public async Task StartDate_IsLatestHireDate()
|
||||
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
|
||||
public async Task SeveralHires_OneLineEach()
|
||||
{
|
||||
const string name = "several";
|
||||
var list = SelectionListUrl(name);
|
||||
@@ -281,15 +285,40 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||
[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 row = SingleRow(result.Value);
|
||||
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 = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
||||
@@ -344,9 +373,28 @@ public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
|
||||
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
|
||||
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
||||
rows.Single(r => r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик");
|
||||
rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull();
|
||||
rows.Single(r => r.Position == "Начальник геодезии")
|
||||
.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 = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||||
|
||||
+85
-13
@@ -21,7 +21,8 @@ public sealed class VacancyReportWorkbookTests
|
||||
DateOnly? submitDate = null,
|
||||
DateOnly? closingDate = null,
|
||||
DateOnly? startDate = null,
|
||||
string? recruiter = "Сергеева Надежда Ивановна") =>
|
||||
string? recruiter = "Сергеева Надежда Ивановна",
|
||||
IReadOnlyList<VacancyReportLine>? lines = null) =>
|
||||
new(
|
||||
position,
|
||||
"офис",
|
||||
@@ -29,11 +30,16 @@ public sealed class VacancyReportWorkbookTests
|
||||
closed,
|
||||
submitDate ?? new DateOnly(2026, 7, 8),
|
||||
closingDate,
|
||||
startDate,
|
||||
"Ким Н.Ю.",
|
||||
null,
|
||||
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)
|
||||
{
|
||||
@@ -72,7 +78,7 @@ public sealed class VacancyReportWorkbookTests
|
||||
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")]
|
||||
[Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")]
|
||||
public void Header_MatchesSample()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||
@@ -84,10 +90,11 @@ public sealed class VacancyReportWorkbookTests
|
||||
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
|
||||
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
|
||||
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
|
||||
StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки");
|
||||
StringAt(sheet, 1, 14).ShouldBe("Комментарии");
|
||||
StringAt(sheet, 1, 16).ShouldBe("Рекрутер");
|
||||
IsEmptyAt(sheet, 1, 17).ShouldBeTrue();
|
||||
StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника");
|
||||
StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки");
|
||||
StringAt(sheet, 1, 15).ShouldBe("Комментарии");
|
||||
StringAt(sheet, 1, 17).ShouldBe("Рекрутер");
|
||||
IsEmptyAt(sheet, 1, 18).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
|
||||
@@ -124,11 +131,76 @@ public sealed class VacancyReportWorkbookTests
|
||||
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
|
||||
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
|
||||
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();
|
||||
StringAt(sheet, 2, 15).ShouldBe("да");
|
||||
StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна");
|
||||
IsEmptyAt(sheet, 2, 15).ShouldBeTrue();
|
||||
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 = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||||
|
||||
Reference in New Issue
Block a user