Отчет по вакансиям (#20)
Tests / frontend-test (push) Successful in 47s
Tests / backend-test (push) Successful in 4m55s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/20
This commit was merged in pull request #20.
This commit is contained in:
2026-08-31 11:00:09 +00:00
parent 95d460c80a
commit e2b6227015
16 changed files with 2198 additions and 0 deletions
@@ -0,0 +1,431 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза
/// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и
/// когда приняли).
///
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
/// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности
/// дробные «1.0» у Number-колонок).
/// </summary>
public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string DataListTitle = "Заявка на подбор — данные";
private const string ApprovalListTitle = "Заявка на подбор — согласование";
private static readonly DateOnly From = new(2026, 7, 1);
private static readonly DateOnly To = new(2026, 8, 31);
private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService;
public VacancyReportServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
}
// База у тестов класса общая, а сервис ищет тип заявки по PageUrl — поэтому
// у каждого теста свои списки и PageUrl (оба выводятся из имени теста), и
// тесты не видят записи друг друга.
private static string SelectionListUrl(string name) => $"/sites/requests/Lists/Подбор-{name}";
private static string HireListUrl(string name) => $"/sites/requests/Lists/Приём-{name}";
private async Task CreateTypeAsync(string name, string listUrl, string pageUrl) =>
await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
CancellationToken.None);
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
/// <summary>Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST.</summary>
private static object Selection(
int id,
string position,
string? objectProject = null,
double vacancies = 1,
string? submitDate = "2026-07-08T00:00:00Z",
string? created = "2026-07-08T00:00:00Z",
string? responsible = "Ким Наталья Юрьевна",
string? openReason = null,
string? openReasonDetails = null) =>
new
{
Id = id,
RequestStatus = "Выполнение",
Position = position,
DepartmentName = "офис",
ObjectProject = objectProject,
// Number-колонка приходит дробной («1.0») — тест держит её такой же.
OpenVacanciesCount = vacancies,
SubmitDate = submitDate,
Created = created,
OpenReason = openReason,
OpenReasonDetails = openReasonDetails,
Responsible = new { Title = responsible }
};
/// <summary>Строка фазы «Исполнение» из списка «Заявка на подбор — согласование».</summary>
private static object Execution(
int ticketId,
string status = "Выполнено",
string? date = "2026-07-22T10:00:00Z",
double stepOrder = 1,
string? executor = "Сергеева Надежда Ивановна") =>
new
{
Id = ticketId * 100 + (int)stepOrder,
TicketId = ticketId,
ApprovalStatus = status,
StepOrder = stepOrder,
Date = date,
TansuSiteApprover = new { Title = executor }
};
/// <summary>
/// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в
/// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по
/// одному Id — в разных партициях Id повторяются.
/// </summary>
private static object Hire(
int selectionId,
string selectionListUrl,
string status = "Закрыта",
string? hireDate = "2026-08-17T00:00:00Z",
string? replaced = null,
string? candidate = "Шакенов Анель") =>
new
{
Id = selectionId * 10,
RequestStatus = status,
SelectionRequestId = (double)selectionId,
SelectionRequestListId = selectionListUrl,
CandidateName = candidate,
ReplacedEmployee = replaced,
HireDate = hireDate
};
private async Task<(VacancyReportService Service, Mock<ISharePointClient> SharePoint)> BuildAsync(
string name,
IReadOnlyList<object> selections,
IReadOnlyList<object>? executions = null,
IReadOnlyList<object>? hires = null)
{
var selectionList = SelectionListUrl(name);
var hireList = HireListUrl(name);
var selectionPage = $"/подбор-{name}";
var hirePage = $"/приём-{name}";
await CreateTypeAsync($"Подбор {name}", selectionList, selectionPage);
await CreateTypeAsync($"Приём {name}", hireList, hirePage);
var sharePoint = new Mock<ISharePointClient>();
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(
SiteUrl, selectionList, It.Is<string>(q => q.Contains("OpenVacanciesCount")),
It.IsAny<CancellationToken>()))
.ReturnsAsync(selections.Select(Json).ToList());
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("Исполнение")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((executions ?? []).Select(Json).ToList());
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(
SiteUrl, hireList, It.Is<string>(q => q.Contains("SelectionRequestId")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((hires ?? []).Select(Json).ToList());
var service = new VacancyReportService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
Options.Create(new ReportsOptions
{
RecruitingSelectionPageUrl = selectionPage,
RecruitingHirePageUrl = hirePage
}),
NullLogger<VacancyReportService>.Instance);
return (service, sharePoint);
}
private static VacancyReportRow SingleRow(VacancyReport report) =>
report.Groups.ShouldHaveSingleItem().Rows.ShouldHaveSingleItem();
[Fact(DisplayName = "Закрытая вакансия: приём даёт число закрытых, «да» и дату выхода")]
public async Task ClosedVacancy_FilledFromHire()
{
const string name = "closed";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Зам нач.юр.отдела")],
[Execution(1, date: "2026-07-08T10:00:00Z")],
[Hire(1, SelectionListUrl(name))]);
var result = await service.GetAsync(From, To, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var row = SingleRow(result.Value);
row.Position.ShouldBe("Зам нач.юр.отдела");
row.Department.ShouldBe("офис");
row.VacanciesCount.ShouldBe(1);
row.ClosedCount.ShouldBe(1);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8));
row.Initiator.ShouldBe("Ким Н.Ю.");
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
var line = row.Lines.ShouldHaveSingleItem();
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
line.CandidateName.ShouldBe("Шакенов Анель");
}
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
public async Task Hire_FromOtherPartition_NotMatched()
{
var (service, _) = await BuildAsync(
"otherPartition",
[Selection(1, "Зам нач.юр.отдела")],
[Execution(1)],
[Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(0);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
}
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
public async Task PartiallyClosed()
{
const string name = "partial";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Логист", vacancies: 2)],
[Execution(1, status: "На исполнении", date: null)],
[Hire(1, SelectionListUrl(name))]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.VacanciesCount.ShouldBe(2);
row.ClosedCount.ShouldBe(1);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial);
}
[Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")]
public async Task ExecutionInProgress_NoClosingDate()
{
var (service, _) = await BuildAsync(
"inprogress",
[Selection(1, "Начальник ОИД")],
[
Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1),
Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosingDate.ShouldBeNull();
// Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он.
row.Recruiter.ShouldBe("Ким Дания Сериковна");
}
[Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")]
public async Task RejectedHire_DoesNotClose()
{
const string name = "rejected";
var (service, _) = await BuildAsync(
name,
[Selection(1, "Юрист")],
[Execution(1)],
[Hire(1, SelectionListUrl(name), status: "Отклонено")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
var row = SingleRow(result.Value);
row.ClosedCount.ShouldBe(0);
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
}
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
public async Task SeveralHires_OneLineEach()
{
const string name = "several";
var list = SelectionListUrl(name);
var (service, _) = await BuildAsync(
name,
[Selection(1, "Разнорабочий", vacancies: 2)],
[Execution(1)],
[
// Намеренно НЕ в порядке дат — проверяем сортировку строк.
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.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 = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
public async Task SubmitDateEmpty_FallsBackToCreated()
{
var (service, _) = await BuildAsync(
"created",
[Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]);
var result = await service.GetAsync(From, To, CancellationToken.None);
SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30));
}
[Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")]
public async Task VacanciesCountEmpty_IsOne()
{
var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]);
var result = await service.GetAsync(From, To, CancellationToken.None);
SingleRow(result.Value).VacanciesCount.ShouldBe(1);
}
[Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")]
public async Task Groups_UnnamedFirstThenAlphabetical()
{
var (service, _) = await BuildAsync(
"groups",
[
Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"),
Selection(2, "Логист"),
Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]);
}
[Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")]
public async Task ReplacedEmployee_OnlyForReplacementReason()
{
var (service, _) = await BuildAsync(
"replaced",
[
Selection(1, "Начальник геодезии",
openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"),
Selection(2, "Инженер ВиВ",
openReason: "Новая штатная единица", openReasonDetails: "по расширению штата")
]);
var result = await service.GetAsync(From, To, CancellationToken.None);
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
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 = "Конец периода раньше начала — ошибка, а не пустой файл")]
public async Task InvertedPeriod_Fails()
{
var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]);
var result = await service.GetAsync(To, From, CancellationToken.None);
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")]
public async Task PeriodFilter_PassedToSharePoint()
{
const string name = "filter";
var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]);
await service.GetAsync(From, To, CancellationToken.None);
sharePoint.Verify(
x => x.GetItemsByListUrlAsync(
SiteUrl,
SelectionListUrl(name),
It.Is<string>(q =>
q.Contains("SubmitDate ge datetime'2026-07-01T00:00:00Z'")
// Верхняя граница — начало следующего дня и строгое lt: иначе
// заявки за сам последний день периода отсеклись бы.
&& q.Contains("SubmitDate lt datetime'2026-09-01T00:00:00Z'")
&& q.Contains("SubmitDate eq null and Created ge")),
It.IsAny<CancellationToken>()),
Times.Once);
}
}
@@ -0,0 +1,386 @@
using NPOI.SS.UserModel;
using NPOI.XSSF.UserModel;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Reports;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
/// </summary>
public sealed class VacancyReportWorkbookTests
{
private static VacancyReportRow Row(
string position,
int vacancies = 1,
int closed = 0,
DateOnly? submitDate = null,
DateOnly? closingDate = null,
DateOnly? startDate = null,
string? recruiter = "Сергеева Надежда Ивановна",
IReadOnlyList<VacancyReportLine>? lines = null) =>
new(
position,
"офис",
vacancies,
closed,
submitDate ?? new DateOnly(2026, 7, 8),
closingDate,
"Ким Н.Ю.",
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
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)
{
var report = new VacancyReport(from, to, groups);
var bytes = VacancyReportWorkbook.Build(report);
return new XSSFWorkbook(new MemoryStream(bytes));
}
private static ISheet BuildListSheet(params VacancyReportGroup[] groups) =>
BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups)
.GetSheet("Список вакансий (все)");
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) =>
sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1);
private static string StringAt(ISheet sheet, int excelRow, int excelCol) =>
CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? "";
private static int IntAt(ISheet sheet, int excelRow, int excelCol) =>
(int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста"));
private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) =>
CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value;
private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol)
{
var cell = CellAt(sheet, excelRow, excelCol);
return cell is null || cell.CellType == CellType.Blank;
}
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
private static string FormulaAt(ISheet sheet, int excelRow, int excelCol)
{
var cell = CellAt(sheet, excelRow, excelCol);
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
}
[Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")]
public void Header_MatchesSample()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
StringAt(sheet, 1, 2).ShouldBe("№");
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
StringAt(sheet, 1, 4).ShouldBe("Подразделение");
StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий");
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
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 = "Шапка колонок с датами закрашена так же, как остальная шапка")]
public void Header_DateColumns_SameStyleAsRestOfHeader()
{
// Регрессия: даты когда-то получали формат через SetDefaultColumnStyle,
// а он в NPOI переписывает стиль УЖЕ существующих ячеек колонки —
// включая шапку, которая от этого лишалась заливки (H1/I1/K1 получали
// DataDate вместо Header). Формат даты теперь ставится только на сами
// ячейки с датами (см. SetDate), шапки колонок с датами он не касается.
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
// NPOI отдаёт на каждый вызов CellStyle новую обёртку — сравнивать
// нужно индекс стиля в книге, а не ссылки на объекты.
var headerStyleIndex = CellAt(sheet, 1, 3)!.CellStyle.Index; // «Вакансия» — заведомо обычная шапка.
foreach (var col in (int[])[8, 9, 11]) // H «Дата подачи заявки», I «Дата закрытия», K «Дата выхода»
CellAt(sheet, 1, col)!.CellStyle.Index.ShouldBe(headerStyleIndex);
}
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
public void DataRow_Layout()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
submitDate: new DateOnly(2026, 3, 30),
closingDate: new DateOnly(2026, 7, 8),
startDate: new DateOnly(2026, 8, 17))
]));
IntAt(sheet, 2, 1).ShouldBe(1);
StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела");
IntAt(sheet, 2, 5).ShouldBe(1);
IntAt(sheet, 2, 6).ShouldBe(1);
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, 13).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 = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
public void DerivedColumns_AreFormulas()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [
Row("Логист", vacancies: 3, closed: 1,
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
]));
FormulaAt(sheet, 2, 7).ShouldBe("E2-F2");
FormulaAt(sheet, 2, 10).ShouldBe("I2-H2");
}
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
public void LeadTime_EmptyWithoutClosingDate()
{
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
FormulaAt(sheet, 2, 10).ShouldBeEmpty();
IsEmptyAt(sheet, 2, 10).ShouldBeTrue();
}
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
public void GroupSeparator_RestartsNumbering()
{
var sheet = BuildListSheet(
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
// Верхний блок без объекта — сразу после шапки, без разделителя.
StringAt(sheet, 2, 3).ShouldBe("Логист");
StringAt(sheet, 3, 3).ShouldBe("Юрист");
StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ");
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
IntAt(sheet, 5, 1).ShouldBe(3);
IntAt(sheet, 5, 2).ShouldBe(1);
IntAt(sheet, 6, 1).ShouldBe(4);
IntAt(sheet, 6, 2).ShouldBe(2);
}
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
public void Totals_SumWholeDataRange()
{
var sheet = BuildListSheet(
new VacancyReportGroup("", [Row("Логист")]),
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)");
FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)");
}
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
public void EmptyReport_HeaderOnly()
{
var sheet = BuildListSheet();
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
IsEmptyAt(sheet, 2, 5).ShouldBeTrue();
}
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
public void FileName_CarriesPeriod()
{
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []);
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
}
// ==================== Лист «Результаты» ====================
private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) =>
Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter);
[Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")]
public void SummarySheet_HorizontalTable()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40%
RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100%
]));
var sheet = workbook.GetSheet("Результаты");
StringAt(sheet, 1, 1).ShouldBe("результаты");
// Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5).
StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий");
StringAt(sheet, 2, 2).ShouldBe("Надежда");
StringAt(sheet, 2, 3).ShouldBe("Алтынай");
StringAt(sheet, 3, 1).ShouldBe("Всего");
IntAt(sheet, 3, 2).ShouldBe(10);
IntAt(sheet, 3, 3).ShouldBe(5);
StringAt(sheet, 4, 1).ShouldBe("в работе (в%)");
CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001);
CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001);
StringAt(sheet, 5, 1).ShouldBe("закрыто");
CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001);
CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001);
}
[Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")]
public void SummarySheet_UnassignedRecruiterLast()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Алтынай", vacancies: 1, closed: 0),
Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null),
]));
var sheet = workbook.GetSheet("Результаты");
// «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на
// то, что её объём (100) больше, чем у Алтынай (1): это не участник
// рейтинга, а технический хвост.
StringAt(sheet, 2, 2).ShouldBe("Алтынай");
StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel);
}
[Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")]
public void SummarySheet_VerticalTables()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе
RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт
]));
var sheet = workbook.GetSheet("Результаты");
// Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк
// горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%.
StringAt(sheet, 7, 1).ShouldBe("Общий объем работ");
IntAt(sheet, 7, 2).ShouldBe(4);
StringAt(sheet, 8, 1).ShouldBe("Надежда");
CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001);
StringAt(sheet, 9, 1).ShouldBe("Алтынай");
CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001);
// Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда
// не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%.
StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе");
IntAt(sheet, 11, 2).ShouldBe(3);
StringAt(sheet, 12, 1).ShouldBe("Надежда");
CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001);
StringAt(sheet, 13, 1).ShouldBe("Алтынай");
CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001);
}
[Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")]
public void SummarySheet_HasTwoPieCharts()
{
var workbook = BuildWorkbook(
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
new VacancyReportGroup("", [
RowFor("Надежда", vacancies: 3, closed: 1),
RowFor("Алтынай", vacancies: 2, closed: 0),
]));
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
var charts = sheet.GetDrawingPatriarch()?.GetCharts();
charts.ShouldNotBeNull();
charts!.Count.ShouldBe(2);
}
[Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")]
public void SummarySheet_EmptyReport_NoTablesOrCharts()
{
var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31));
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
sheet.GetDrawingPatriarch().ShouldBeNull();
}
}