diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index df56e95..97274c9 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -34,8 +34,15 @@
+
+
+
diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs
new file mode 100644
index 0000000..f25a271
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Net.Http.Headers;
+using Tnsu.ApprovalRoute.Api.Auth;
+using Tnsu.ApprovalRoute.Application.Reports;
+
+namespace Tnsu.ApprovalRoute.Api.Controllers;
+
+///
+/// Выгрузки отчётов файлами. В отличие от остальных контроллеров отдаёт не
+/// ApiResponse, а сам файл: получатель — кнопка «скачать» в портале и Power
+/// Automate, обоим нужен готовый xlsx, а не JSON с телом в base64.
+///
+[ApiController]
+[Authorize(Policy = AuthPolicies.Any)]
+[Route("api/reports")]
+public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase
+{
+ private const string XlsxContentType =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ ///
+ /// Отчёт по вакансиям за период — лист «Список вакансий (все)» по данным
+ /// заявок на подбор и на приём.
+ ///
+ /// Период отбирает заявки по «Дате подачи заявки» (колонка H отчёта). Заявки
+ /// на приём периодом НЕ ограничены: у вакансии, поданной в начале интервала,
+ /// приём мог оформляться уже после его конца.
+ ///
+ [HttpGet("vacancies")]
+ [Produces(XlsxContentType)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task Vacancies(
+ [FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct = default)
+ {
+ var result = await vacancies.GetAsync(from, to, ct);
+ if (result.IsFailed)
+ return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
+
+ var report = result.Value;
+ var file = VacancyReportWorkbook.Build(report);
+
+ // Имя файла с кириллицей и пробелами — через ContentDispositionHeaderValue:
+ // он сам добавит filename* в RFC 5987, иначе браузер сохранит файл
+ // с искажённым именем.
+ var disposition = new ContentDispositionHeaderValue("attachment");
+ disposition.SetHttpFileName(VacancyReportWorkbook.FileName(report));
+ Response.Headers.ContentDisposition = disposition.ToString();
+
+ return File(file, XlsxContentType);
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs
index 48a3aee..21beda3 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Program.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs
@@ -14,6 +14,7 @@ using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders;
+using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure;
@@ -57,6 +58,7 @@ builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.Configure(builder.Configuration.GetSection("Reminders"));
builder.Services.Configure(builder.Configuration.GetSection("ListPartitioning"));
+builder.Services.Configure(builder.Configuration.GetSection("Reports"));
builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
index 0a35929..27a0740 100644
--- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs
@@ -3,6 +3,7 @@ using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
+using Tnsu.ApprovalRoute.Application.Reports;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -19,6 +20,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/ExecutionRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/ExecutionRow.cs
new file mode 100644
index 0000000..592feb0
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/ExecutionRow.cs
@@ -0,0 +1,44 @@
+using System.Text.Json;
+
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Строка ФАЗЫ ИСПОЛНЕНИЯ из списка «Заявка на подбор — согласование». Отчёту она
+/// даёт две колонки, которых нет в самой заявке: рекрутёр (исполнитель шага) и
+/// дата закрытия вакансии.
+///
+/// Дата закрытия — это колонка Date выполненной строки: request-portal
+/// перезаписывает её в момент решения по шагу (см. setDecision в
+/// shared/approval/approvalList.service.ts), так что у строки со статусом
+/// «Выполнено» там именно дата закрытия, а не дата получения шага в работу.
+///
+internal sealed record ExecutionRow(
+ int TicketId,
+ int StepOrder,
+ string Status,
+ string? ExecutorName,
+ DateOnly? Date)
+{
+ public const string Select =
+ $"$select=Id,TicketId,ApprovalStatus,StepOrder,Date,TansuSiteApprover/Title" +
+ $"&$expand=TansuSiteApprover&$filter=Phase eq '{Phase}'&$top=5000";
+
+ private const string Phase = "Исполнение";
+
+ /// Статус выполненной строки исполнения — по нему считаем вакансию закрытой.
+ public const string Done = "Выполнено";
+
+ public static ExecutionRow? TryParse(JsonElement item)
+ {
+ // TicketId — Lookup на заявку; без него строку не с чем связать.
+ var ticketId = SharePointJson.Int(item, "TicketId");
+ if (ticketId is null) return null;
+
+ return new ExecutionRow(
+ ticketId.Value,
+ SharePointJson.Int(item, "StepOrder") ?? 0,
+ SharePointJson.String(item, "ApprovalStatus") ?? string.Empty,
+ SharePointJson.PersonTitle(item, "TansuSiteApprover"),
+ SharePointJson.Date(item, "Date"));
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/HireLinkRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/HireLinkRow.cs
new file mode 100644
index 0000000..4a22cdd
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/HireLinkRow.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Строка списка «Прием на работу — данные» в том объёме, в каком она нужна отчёту:
+/// на какую заявку на подбор ссылается, кого и когда приняли.
+///
+internal sealed record HireLinkRow(
+ int SelectionRequestId,
+ string? SelectionRequestListId,
+ string Status,
+ string? CandidateName,
+ string? ReplacedEmployee,
+ DateOnly? HireDate)
+{
+ public const string Select =
+ "$select=Id,RequestStatus,SelectionRequestId,SelectionRequestListId," +
+ "CandidateName,ReplacedEmployee,HireDate&$top=5000";
+
+ /// Заявка на приём без ссылки на подбор отчёту бесполезна — её отбрасываем.
+ public static HireLinkRow? TryParse(JsonElement item)
+ {
+ var selectionId = SharePointJson.Int(item, "SelectionRequestId");
+ if (selectionId is null or <= 0) return null;
+
+ return new HireLinkRow(
+ selectionId.Value,
+ SharePointJson.String(item, "SelectionRequestListId"),
+ SharePointJson.String(item, "RequestStatus") ?? string.Empty,
+ SharePointJson.String(item, "CandidateName"),
+ SharePointJson.String(item, "ReplacedEmployee"),
+ SharePointJson.Date(item, "HireDate"));
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/ReportsOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/ReportsOptions.cs
new file mode 100644
index 0000000..dc75ad7
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/ReportsOptions.cs
@@ -0,0 +1,19 @@
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+/// Настройки отчётов (секция Reports в конфигурации).
+public sealed class ReportsOptions
+{
+ ///
+ /// PageUrl типа заявки «Заявка на подбор» (см. request-portal,
+ /// forms/RecruitingSelection/model.ts, FORM_ID) — по нему ищется RequestType,
+ /// из партиций которого берутся сами вакансии.
+ ///
+ public string RecruitingSelectionPageUrl { get; set; } = "recruiting-selection";
+
+ ///
+ /// PageUrl типа заявки «Приём на работу» — из его партиций берутся закрытия
+ /// вакансий: заявка на приём ссылается на заявку на подбор колонками
+ /// SelectionRequestId / SelectionRequestListId.
+ ///
+ public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/SelectionRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/SelectionRow.cs
new file mode 100644
index 0000000..b533139
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/SelectionRow.cs
@@ -0,0 +1,56 @@
+using System.Text.Json;
+
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Строка списка «Заявка на подбор — данные». Имена колонок — как в request-portal
+/// (provisioning.json формы RecruitingSelection).
+///
+internal sealed record SelectionRow(
+ int Id,
+ string ListId,
+ string Status,
+ string? Position,
+ string? DepartmentName,
+ string? ObjectProject,
+ int VacanciesCount,
+ DateOnly? SubmitDate,
+ string? Initiator,
+ string? OpenReason,
+ string? OpenReasonDetails)
+{
+ ///
+ /// Поля, которые нужны отчёту, — перечислены явно: список данных широкий
+ /// (полсотни колонок, часть — Note с многострочным текстом), и тянуть его
+ /// целиком по всем партициям только ради шестнадцати колонок незачем.
+ ///
+ public const string Select =
+ "$select=Id,RequestStatus,Position,DepartmentName,ObjectProject,OpenVacanciesCount," +
+ "SubmitDate,Created,OpenReason,OpenReasonDetails,Responsible/Title&$expand=Responsible&$top=5000";
+
+ public static SelectionRow? TryParse(JsonElement item, string listId)
+ {
+ var id = SharePointJson.Int(item, "Id");
+ if (id is null) return null;
+
+ return new SelectionRow(
+ id.Value,
+ listId,
+ SharePointJson.String(item, "RequestStatus") ?? string.Empty,
+ SharePointJson.String(item, "Position"),
+ SharePointJson.String(item, "DepartmentName"),
+ SharePointJson.String(item, "ObjectProject"),
+ // Пустое «Количество открытых вакансий» — это одна вакансия: заявку
+ // подают на конкретную должность, и ноль вакансий в отчёте сломал бы
+ // и итог по колонке E, и признак «закрыта» (0 из 0).
+ SharePointJson.Int(item, "OpenVacanciesCount") is { } count and > 0 ? count : 1,
+ // Дата подачи — поле формы, заполняется руками и может быть пустым;
+ // тогда берём дату создания элемента. Тем же правилом заявки
+ // отбираются по интервалу (см. VacancyReportService.PeriodFilter) —
+ // иначе заявка попадала бы в отчёт, но с пустой колонкой H.
+ SharePointJson.Date(item, "SubmitDate") ?? SharePointJson.Date(item, "Created"),
+ SharePointJson.PersonTitle(item, "Responsible"),
+ SharePointJson.String(item, "OpenReason"),
+ SharePointJson.String(item, "OpenReasonDetails"));
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/SharePointJson.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/SharePointJson.cs
new file mode 100644
index 0000000..22f3ea5
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/SharePointJson.cs
@@ -0,0 +1,52 @@
+using System.Globalization;
+using System.Text.Json;
+
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Чтение полей из ответа SharePoint REST. Те же грабли, что разобраны в
+/// Reminders/ApprovalRow: Number-колонка приходит дробной («1.0»), и обычный
+/// TryGetInt32 на ней возвращает false, а часть полей SharePoint отдаёт строками.
+///
+internal static class SharePointJson
+{
+ public static string? String(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString() is { Length: > 0 } s ? s : null
+ : null;
+
+ public static int? Int(JsonElement e, string name)
+ {
+ if (!e.TryGetProperty(name, out var v)) return null;
+
+ if (v.ValueKind == JsonValueKind.Number)
+ {
+ if (v.TryGetInt32(out var i)) return i;
+ return v.TryGetDouble(out var d) && d is >= int.MinValue and <= int.MaxValue ? (int)Math.Round(d) : null;
+ }
+
+ if (v.ValueKind != JsonValueKind.String) return null;
+
+ return double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)
+ && parsed is >= int.MinValue and <= int.MaxValue
+ ? (int)Math.Round(parsed)
+ : null;
+ }
+
+ ///
+ /// Дата БЕЗ приведения к другой зоне: колонки отчёта — dateOnly, SharePoint
+ /// отдаёт их полуночью, и любой сдвиг зоны увёл бы день на соседний.
+ ///
+ public static DateOnly? Date(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v)
+ && v.ValueKind == JsonValueKind.String
+ && DateTimeOffset.TryParse(v.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
+ ? DateOnly.FromDateTime(parsed.DateTime)
+ : null;
+
+ /// Title развёрнутого персонального поля (…/Title через $expand). null — поле пустое.
+ public static string? PersonTitle(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var person) && person.ValueKind == JsonValueKind.Object
+ ? String(person, "Title")
+ : null;
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs
new file mode 100644
index 0000000..ead3c19
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs
@@ -0,0 +1,72 @@
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Отчёт по вакансиям за период — ровно то, что ложится на лист «Список вакансий
+/// (все)» в xlsx: интервал плюс вакансии, разбитые по объектам/проектам.
+///
+public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList Groups);
+
+///
+/// Объект/проект и его вакансии. Имя группы становится строкой-разделителем на
+/// листе (как «КЭС КУРЧАТОВ» в образце отчёта); пустое имя — вакансии, у которых
+/// объект в заявке не указан, они идут первым блоком без разделителя.
+///
+public sealed record VacancyReportGroup(string Name, IReadOnlyList Rows);
+
+///
+/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк
+/// на листе, там они объединяются по вертикали; то, что различается от приёма к
+/// приёму, лежит в Lines.
+///
+/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся:
+/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в
+/// файле поправят руками.
+///
+public sealed record VacancyReportRow(
+ // «Вакансия» — должность из заявки на подбор.
+ string Position,
+ // «Подразделение» ответственного за подачу заявки.
+ string? Department,
+ // «Кол-во вакансий».
+ int VacanciesCount,
+ // «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
+ int ClosedCount,
+ // «Дата подачи заявки».
+ DateOnly? SubmitDate,
+ // «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
+ DateOnly? ClosingDate,
+ // «Инициатор заявки».
+ string? Initiator,
+ // «Закрыта вакансия»: да / нет / частично.
+ string ClosedFlag,
+ // «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
+ string? Recruiter,
+ // Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine).
+ IReadOnlyList Lines);
+
+///
+/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками.
+/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии»
+/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и
+/// свой «взамен кого».
+///
+/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все
+/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с
+/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число
+/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется.
+///
+public sealed record VacancyReportLine(
+ // «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём.
+ string? CandidateName,
+ // «Дата выхода» — дата приёма этого кандидата.
+ DateOnly? StartDate,
+ // «Вместо сотрудника Ф.И.О.».
+ string? ReplacedEmployee);
+
+/// Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.
+public static class VacancyClosedFlags
+{
+ public const string Yes = "да";
+ public const string No = "нет";
+ public const string Partial = "частично";
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs
new file mode 100644
index 0000000..a03dc40
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs
@@ -0,0 +1,410 @@
+using System.Globalization;
+using FluentResults;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Tnsu.ApprovalRoute.Application.ListPartitioning;
+using Tnsu.ApprovalRoute.Domain.Entities;
+using Tnsu.ApprovalRoute.Infrastructure.Persistence;
+using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор»
+/// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на
+/// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки
+/// SelectionRequestId / SelectionRequestListId заявки на приём.
+///
+/// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного
+/// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow).
+///
+/// ПОЧЕМУ ЗДЕСЬ items REST ПО ПАРТИЦИЯМ, А НЕ SEARCH (разбирались 2026-08-28).
+/// Search выглядит уместнее — один KQL сразу по всем партициям, и отставание
+/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
+/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
+/// через REST):
+/// • «Заявка на подбор»: OpenReason, OpenReasonDetails;
+/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
+/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
+/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
+/// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не
+/// помогает — пробовали.
+///
+/// Причина, если понадобится чинить: одноимённые колонки в РАЗНЫХ списках — это
+/// разные поля со своими GUID, а значит и со своими crawled property
+/// (ows_Position, ows_Position1, …); автосозданное managed property привязано
+/// только к одному из них. Поэтому, например, PositionOWSTEXT отдаёт значения в
+/// «Заявка на подбор — данные» и молчит в «Прием на работу — данные». Проверять
+/// надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим
+/// занимается `sp-write props` (репозиторий C:\work\git\tools), он же говорит,
+/// нужно ли домапить crawled property или свойства нет вовсе.
+///
+/// Значит эти поля пришлось бы дочитывать построчно батчами по 100 — и на 1000
+/// заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST.
+/// В list view threshold (5000) REST при этом не упирается by design: партиции
+/// режутся на 4900 элементов (ListPartitioning:ItemCountThreshold), то есть ни
+/// один $filter не сканирует больше предела.
+///
+/// Вернуться к Search имеет смысл, только если в Search Schema заведут
+/// работающие managed properties для перечисленных выше полей.
+///
+public sealed class VacancyReportService(
+ ApprovalRouteDbContext db,
+ ISharePointClient sharePoint,
+ ListPartitionRegistry partitionRegistry,
+ IOptions options,
+ ILogger logger)
+{
+ public async Task> GetAsync(
+ DateOnly from, DateOnly to, CancellationToken cancellationToken)
+ {
+ if (to < from)
+ return Result.Fail($"Конец периода ({to:dd.MM.yyyy}) раньше его начала ({from:dd.MM.yyyy}).");
+
+ var selectionType = await FindTypeAsync(options.Value.RecruitingSelectionPageUrl, cancellationToken);
+ if (selectionType is null)
+ return Result.Fail(
+ $"Тип заявки «{options.Value.RecruitingSelectionPageUrl}» не настроен (нет списка или сайта) — " +
+ "отчёт по вакансиям строить не из чего.");
+
+ var selections = await ReadSelectionsAsync(selectionType, from, to, cancellationToken);
+
+ // Заявки на приём читаются ВСЕ, без ограничения периодом: у вакансии,
+ // поданной в начале интервала, приём мог оформляться уже после его конца,
+ // и фильтр по дате обнулил бы ей и «Дату закрытия», и «Дату выхода».
+ // Дальше они отбираются по ссылке на заявку на подбор.
+ var hireType = await FindTypeAsync(options.Value.RecruitingHirePageUrl, cancellationToken);
+ if (hireType is null)
+ logger.LogWarning(
+ "Тип заявки «{PageUrl}» не настроен — отчёт по вакансиям будет без закрытий",
+ options.Value.RecruitingHirePageUrl);
+
+ var hires = hireType is null
+ ? (IReadOnlyList)[]
+ : await ReadHiresAsync(hireType, cancellationToken);
+
+ var hiresBySelection = hires
+ .GroupBy(x => (x.SelectionRequestId, ListId: NormalizeListId(x.SelectionRequestListId)))
+ .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList());
+
+ return Result.Ok(new VacancyReport(from, to, Group(selections, hiresBySelection)));
+ }
+
+ private async Task FindTypeAsync(string pageUrl, CancellationToken cancellationToken) =>
+ await db.RequestTypes.AsNoTracking()
+ .FirstOrDefaultAsync(
+ x => x.PageUrl == pageUrl && x.ListId != null && x.SiteUrl != null, cancellationToken);
+
+ ///
+ /// Заявки на подбор за период по ВСЕМ партициям типа — вакансия, уехавшая в
+ /// архивный список, из отчёта пропадать не должна: подбор по ней мог начаться
+ /// задолго до того, как для новых заявок завели следующую партицию.
+ ///
+ private async Task> ReadSelectionsAsync(
+ RequestType type, DateOnly from, DateOnly to, CancellationToken cancellationToken)
+ {
+ var result = new List();
+
+ foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
+ {
+ try
+ {
+ result.AddRange(await ReadSelectionPartitionAsync(type, listId, from, to, cancellationToken));
+ }
+ catch (Exception e)
+ {
+ // Недоступная партиция (архивный список переименовали или удалили
+ // руками) не должна обнулять весь отчёт — в файл попадёт то, что
+ // прочиталось, а причина уйдёт в лог.
+ logger.LogError(
+ e, "Отчёт по вакансиям: партиция №{Number} ({ListId}) типа «{Type}» не прочитана",
+ number, listId, type.Name);
+ }
+ }
+
+ return result;
+ }
+
+ private async Task> ReadSelectionPartitionAsync(
+ RequestType type, string listId, DateOnly from, DateOnly to, CancellationToken cancellationToken)
+ {
+ var siteUrl = type.SiteUrl!;
+
+ var items = await sharePoint.GetItemsByListUrlAsync(
+ siteUrl, listId, $"{SelectionRow.Select}&{PeriodFilter(from, to)}", cancellationToken);
+
+ var rows = items
+ .Select(item => SelectionRow.TryParse(item, listId))
+ .OfType()
+ .ToList();
+
+ // Список согласования читаем только если в партиции вообще нашлись
+ // заявки за период: у пустой партиции он всё равно ничего не добавит.
+ if (rows.Count == 0) return [];
+
+ var executions = await ReadExecutionsAsync(siteUrl, listId, cancellationToken);
+
+ return rows
+ .Select(row => new SelectionWithExecution(
+ row, executions.TryGetValue(row.Id, out var found) ? found : ExecutionInfo.Empty))
+ .ToList();
+ }
+
+ ///
+ /// Строки фазы «Исполнение» партиции, свёрнутые по заявке. Заголовок списка
+ /// согласования выводится из ЖИВОГО заголовка списка данных, а не из url:
+ /// список могли переименовать руками.
+ ///
+ private async Task> ReadExecutionsAsync(
+ string siteUrl, string listId, CancellationToken cancellationToken)
+ {
+ var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
+ var approvalListTitle = ApprovalListTitleFor(dataListTitle);
+ if (approvalListTitle is null)
+ {
+ logger.LogWarning(
+ "Отчёт по вакансиям: у списка «{List}» нет парного списка согласования — " +
+ "колонки «Рекрутер» и «Дата закрытия вакансии» останутся пустыми",
+ dataListTitle);
+ return [];
+ }
+
+ var items = await sharePoint.GetItemsByListTitleAsync(
+ siteUrl, approvalListTitle, ExecutionRow.Select, cancellationToken);
+
+ return items
+ .Select(ExecutionRow.TryParse)
+ .OfType()
+ .GroupBy(x => x.TicketId)
+ .ToDictionary(g => g.Key, ExecutionInfo.From);
+ }
+
+ private async Task> ReadHiresAsync(
+ RequestType type, CancellationToken cancellationToken)
+ {
+ var result = new List();
+
+ foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
+ {
+ try
+ {
+ var items = await sharePoint.GetItemsByListUrlAsync(
+ type.SiteUrl!, listId, HireLinkRow.Select, cancellationToken);
+
+ result.AddRange(items.Select(HireLinkRow.TryParse).OfType());
+ }
+ catch (Exception e)
+ {
+ logger.LogError(
+ e, "Отчёт по вакансиям: приёмы из партиции №{Number} ({ListId}) не прочитаны", number, listId);
+ }
+ }
+
+ return result;
+ }
+
+ /// Все партиции типа — архив плюс текущая (она в реестр не пишется, а синтезируется).
+ private async Task> PartitionsOfAsync(
+ RequestType type, CancellationToken cancellationToken)
+ {
+ var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
+ var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
+
+ return archived
+ .Select(x => (x.ListId, x.Number))
+ .Append((current.ListId, current.Number))
+ .ToList();
+ }
+
+ private static VacancyReportRow Build(
+ SelectionWithExecution selection,
+ IReadOnlyDictionary<(int, string), IReadOnlyList> hiresBySelection)
+ {
+ var (row, execution) = selection;
+
+ var hires = hiresBySelection.TryGetValue((row.Id, NormalizeListId(row.ListId)), out var found)
+ ? found
+ : [];
+
+ // Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
+ // кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
+ // ложью («вот кого приняли»).
+ var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
+
+ return new VacancyReportRow(
+ row.Position ?? string.Empty,
+ row.DepartmentName,
+ row.VacanciesCount,
+ accepted.Count,
+ row.SubmitDate,
+ execution.ClosingDate,
+ ShortName(row.Initiator),
+ ClosedFlag(row.VacanciesCount, accepted.Count),
+ execution.Recruiter,
+ Lines(row, accepted));
+ }
+
+ ///
+ /// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
+ /// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
+ /// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
+ /// заполняется из причины открытия вакансии.
+ ///
+ private static IReadOnlyList Lines(SelectionRow row, IReadOnlyList accepted)
+ {
+ if (accepted.Count == 0)
+ return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
+
+ 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();
+ }
+
+ ///
+ /// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле
+ /// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если
+ /// причина — замена уволенного: при «новой штатной единице» в том же поле
+ /// лежит совсем другой текст.
+ ///
+ /// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии
+ /// он одинаковый — это нормально: причина открытия у вакансии одна.
+ ///
+ /// hire = null — у вакансии приёмов нет вовсе, остаётся только откат.
+ ///
+ 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;
+ }
+
+ private static string ClosedFlag(int vacancies, int closed) => closed switch
+ {
+ 0 => VacancyClosedFlags.No,
+ _ when closed >= vacancies => VacancyClosedFlags.Yes,
+ _ => VacancyClosedFlags.Partial,
+ };
+
+ ///
+ /// «Фамилия Имя Отчество» → «Фамилия И.О.» — так инициатор записан в образце
+ /// отчёта («Ким Н.Ю.»). Имя из одного слова остаётся как есть.
+ ///
+ private static string? ShortName(string? fullName)
+ {
+ if (string.IsNullOrWhiteSpace(fullName)) return null;
+
+ var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (parts.Length < 2) return fullName.Trim();
+
+ var initials = string.Concat(parts.Skip(1).Select(x => $"{char.ToUpperInvariant(x[0])}."));
+ return $"{parts[0]} {initials}";
+ }
+
+ ///
+ /// Группировка по объекту/проекту — она же порядок строк на листе: сначала
+ /// вакансии без объекта (в образце это верхний блок без разделителя), затем
+ /// объекты по алфавиту, внутри группы — по дате подачи заявки.
+ ///
+ private static IReadOnlyList Group(
+ IEnumerable selections,
+ IReadOnlyDictionary<(int, string), IReadOnlyList> hiresBySelection) => selections
+ .GroupBy(x => (x.Row.ObjectProject ?? string.Empty).Trim())
+ .OrderBy(g => g.Key.Length == 0 ? 0 : 1)
+ .ThenBy(g => g.Key, StringComparer.CurrentCulture)
+ .Select(g => new VacancyReportGroup(
+ g.Key,
+ g.OrderBy(x => x.Row.SubmitDate ?? DateOnly.MaxValue)
+ .ThenBy(x => x.Row.Id)
+ .Select(x => Build(x, hiresBySelection))
+ .ToList()))
+ .ToList();
+
+ ///
+ /// Фильтр периода по «Дате подачи заявки» с откатом на дату создания элемента:
+ /// SubmitDate — поле формы, заполняется руками и часто пустое, и без отката
+ /// такие заявки не попали бы в отчёт вообще.
+ ///
+ /// Верхняя граница — по НАЧАЛУ следующего дня и строгим «lt»: даты в SharePoint
+ /// лежат с временем, и «le {to}» отсекло бы всё, что записано в сам день `to`
+ /// позже полуночи.
+ ///
+ private static string PeriodFilter(DateOnly from, DateOnly to)
+ {
+ var start = from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var end = to.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+
+ return "$filter=" +
+ $"(SubmitDate ge datetime'{start}T00:00:00Z' and SubmitDate lt datetime'{end}T00:00:00Z') or " +
+ $"(SubmitDate eq null and Created ge datetime'{start}T00:00:00Z' " +
+ $"and Created lt datetime'{end}T00:00:00Z')";
+ }
+
+ ///
+ /// Имя парного списка согласования по имени списка данных. Ещё одна копия
+ /// конвенции «<Форма> <N> — данные|согласование»: ListTitleConvention
+ /// в Infrastructure — internal, а здесь нужен только этот один переход.
+ ///
+ private static string? ApprovalListTitleFor(string? dataListTitle)
+ {
+ const string dataSuffix = " — данные";
+ if (string.IsNullOrWhiteSpace(dataListTitle) || !dataListTitle.EndsWith(dataSuffix, StringComparison.Ordinal))
+ return null;
+
+ return string.Concat(dataListTitle.AsSpan(0, dataListTitle.Length - dataSuffix.Length), " — согласование");
+ }
+
+ ///
+ /// Приведение server-relative url партиции к сравнимому виду: портал пишет
+ /// SelectionRequestListId не всегда в том же регистре и с тем же хвостовым слешем.
+ ///
+ private static string NormalizeListId(string? listId) =>
+ (listId ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
+}
+
+/// Заявка на подбор вместе с тем, что о ней знает её фаза исполнения.
+internal sealed record SelectionWithExecution(SelectionRow Row, ExecutionInfo Execution);
+
+/// Статусы списка «Прием на работу — данные», которые важны отчёту.
+internal static class HireRequestStatus
+{
+ public const string Rejected = "Отклонено";
+}
+
+/// Значение колонки «Причина открытия вакансии», при котором заполнено «вместо кого».
+internal static class SelectionOpenReason
+{
+ public const string Replacement = "Замена уволенного сотрудника";
+}
+
+///
+/// Что фаза исполнения заявки на подбор даёт отчёту. Рекрутёр — исполнитель
+/// ПОСЛЕДНЕГО шага исполнения (шагов может быть несколько, вакансию довёл
+/// последний); дата закрытия появляется только когда ВСЕ шаги исполнения
+/// выполнены — пока хоть один в работе, вакансия не закрыта.
+///
+internal sealed record ExecutionInfo(string? Recruiter, DateOnly? ClosingDate)
+{
+ public static readonly ExecutionInfo Empty = new(null, null);
+
+ public static ExecutionInfo From(IEnumerable rows)
+ {
+ var ordered = rows.OrderBy(x => x.StepOrder).ToList();
+ if (ordered.Count == 0) return Empty;
+
+ var recruiter = ordered
+ .Select(x => x.ExecutorName)
+ .LastOrDefault(x => !string.IsNullOrWhiteSpace(x));
+
+ var closingDate = ordered.All(x => x.Status == ExecutionRow.Done)
+ ? ordered.Select(x => x.Date).Where(x => x is not null).Max()
+ : null;
+
+ return new ExecutionInfo(recruiter, closingDate);
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs
new file mode 100644
index 0000000..af36437
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs
@@ -0,0 +1,70 @@
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в
+/// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в
+/// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше
+/// рисуется как две круговые диаграммы.
+///
+/// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а
+/// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр.
+/// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же,
+/// как единица подсчёта работает на первом листе.
+///
+public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList Recruiters)
+{
+ /// Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами.
+ public static readonly VacancyReportSummary Empty = new(0, 0, []);
+
+ public static VacancyReportSummary Build(VacancyReport report)
+ {
+ var rows = report.Groups.SelectMany(g => g.Rows).ToList();
+ if (rows.Count == 0) return Empty;
+
+ var recruiters = rows
+ .GroupBy(r => string.IsNullOrWhiteSpace(r.Recruiter) ? UnassignedLabel : r.Recruiter!.Trim())
+ .Select(g => new
+ {
+ Name = g.Key,
+ Total = g.Sum(r => r.VacanciesCount),
+ Closed = g.Sum(r => r.ClosedCount),
+ })
+ .ToList();
+
+ var grandTotal = recruiters.Sum(r => r.Total);
+ var grandInProgress = recruiters.Sum(r => r.Total - r.Closed);
+
+ // «Без рекрутёра» — всегда последним и независимо от объёма: это не
+ // реальный участник рейтинга, чья позиция в сортировке имела бы смысл.
+ var ordered = recruiters
+ .OrderByDescending(r => r.Name != UnassignedLabel)
+ .ThenByDescending(r => r.Total)
+ .Select(r => new RecruiterBreakdown(r.Name, r.Total, r.Total - r.Closed, r.Closed))
+ .ToList();
+
+ return new VacancyReportSummary(grandTotal, grandInProgress, ordered);
+ }
+
+ /// Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя).
+ public const string UnassignedLabel = "Без рекрутёра";
+}
+
+///
+/// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода
+/// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с
+/// Total/InProgress при округлении.
+///
+public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed)
+{
+ /// Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы).
+ public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0;
+
+ /// Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы).
+ public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0;
+
+ /// Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице.
+ public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0;
+
+ /// Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице.
+ public double ClosedRate => Total > 0 ? (double)Closed / Total : 0;
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs
new file mode 100644
index 0000000..82e6539
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs
@@ -0,0 +1,556 @@
+using NPOI.OOXML.XSSF.UserModel;
+using NPOI.SS.UserModel;
+using NPOI.SS.UserModel.Charts;
+using NPOI.SS.Util;
+using NPOI.XSSF.UserModel;
+
+namespace Tnsu.ApprovalRoute.Application.Reports;
+
+///
+/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
+/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
+/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
+/// круговыми диаграммами).
+///
+/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
+/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
+/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
+///
+/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
+/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
+/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
+///
+/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
+/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
+/// руками, и после правки строки цифры должны пересчитаться сами.
+///
+public static class VacancyReportWorkbook
+{
+ private const string SheetName = "Список вакансий (все)";
+ private const string SummarySheetName = "Результаты";
+
+ private const string DateFormat = "dd.mm.yyyy";
+ private const string WholePercentFormat = "0%";
+ private const string PrecisePercentFormat = "0.00%";
+
+ /// Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).
+ private static readonly (string Title, double Width)[] Columns =
+ [
+ (" ", 6.1),
+ ("№", 4.9),
+ ("Вакансия", 57.4),
+ ("Подразделение", 17.1),
+ ("Кол-во вакансий", 8.3),
+ ("количество закрытых вакансий", 9.1),
+ ("количество вакансий в работе", 11.6),
+ ("Дата подачи заявки", 14.7),
+ ("Дата закрытия вакансии", 14.9),
+ ("Срок отработки", 12.3),
+ ("Дата выхода", 13.9),
+ ("Ф.И.О. нанимаемого сотрудника", 30.0),
+ ("Инициатор заявки", 26.9),
+ ("Вместо сотрудника Ф.И.О.", 26.0),
+ ("Комментарии", 51.3),
+ ("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
+ ("Рекрутер", 23.9),
+ ];
+
+ // Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
+ private const int ColTotalNumber = 0;
+ private const int ColGroupNumber = 1;
+ private const int ColPosition = 2;
+ private const int ColDepartment = 3;
+ private const int ColVacancies = 4;
+ private const int ColClosed = 5;
+ private const int ColInProgress = 6;
+ private const int ColSubmitDate = 7;
+ private const int ColClosingDate = 8;
+ private const int ColLeadTime = 9;
+ private const int ColStartDate = 10;
+ private const int ColCandidateName = 11;
+ private const int ColInitiator = 12;
+ private const int ColReplaced = 13;
+ private const int ColComment = 14;
+ private const int ColClosedFlag = 15;
+ private const int ColRecruiter = 16;
+
+ ///
+ /// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
+ /// общие — при нескольких приёмах по одной заявке на подбор они объединяются
+ /// по вертикали (см. WriteVacancy).
+ ///
+ private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
+
+ private const int HeaderRowIdx = 0;
+
+ public static byte[] Build(VacancyReport report)
+ {
+ using var workbook = new XSSFWorkbook();
+ var styles = new Styles(workbook);
+
+ BuildVacancyListSheet(workbook, styles, report);
+ BuildSummarySheet(workbook, styles, report);
+
+ using var stream = new MemoryStream();
+ workbook.Write(stream, leaveOpen: true);
+ return stream.ToArray();
+ }
+
+ /// Имя файла — с периодом, как в ручном отчёте HR.
+ public static string FileName(VacancyReport report) =>
+ $"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
+
+ // ==================== Лист 1: «Список вакансий (все)» ====================
+
+ private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
+ {
+ var sheet = (XSSFSheet)workbook.CreateSheet(SheetName);
+
+ WriteHeader(sheet, styles);
+
+ var rowIdx = HeaderRowIdx + 1;
+ var totalNumber = 0;
+ var firstDataRowIdx = rowIdx;
+
+ foreach (var group in report.Groups)
+ {
+ if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name);
+
+ var groupNumber = 0;
+ foreach (var item in group.Rows)
+ rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item);
+ }
+
+ var lastDataRowIdx = rowIdx - 1;
+
+ // Итоги — только если есть что суммировать: SUM по пустому диапазону
+ // (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном.
+ if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx);
+
+ FinishVacancyListSheet(sheet, styles, lastDataRowIdx);
+ }
+
+ private static void WriteHeader(XSSFSheet sheet, Styles styles)
+ {
+ var row = sheet.CreateRow(HeaderRowIdx);
+ // Заголовки длинные и переносятся — без заданной высоты Excel показал бы
+ // одну строку из трёх (высота 90 — из образца отчёта).
+ row.HeightInPoints = 90f;
+
+ for (var col = 0; col < Columns.Length; col++)
+ {
+ var (title, width) = Columns[col];
+ var cell = row.CreateCell(col);
+ cell.SetCellValue(title);
+ cell.CellStyle = styles.Header;
+ sheet.SetColumnWidth(col, (int)Math.Round(width * 256));
+ }
+ }
+
+ ///
+ /// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
+ /// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
+ /// начинается заново — так же, как в ручном отчёте.
+ ///
+ private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
+ {
+ var row = sheet.CreateRow(rowIdx);
+ for (var col = 0; col < Columns.Length; col++)
+ row.CreateCell(col).CellStyle = styles.GroupSeparator;
+
+ row.GetCell(ColGroupNumber).SetCellValue(name);
+ }
+
+ ///
+ /// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
+ /// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
+ ///
+ /// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
+ /// вертикали: у объединённой области Excel хранит значение в верхней левой
+ /// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
+ /// SUM в итогах посчитал бы одно и то же несколько раз).
+ ///
+ private static int WriteVacancy(
+ XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
+ {
+ var lines = item.Lines;
+ 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++)
+ row.CreateCell(col).CellStyle = styles.Data;
+ }
+
+ var first = sheet.GetRow(firstRowIdx);
+
+ first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
+ first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
+ first.GetCell(ColPosition).SetCellValue(item.Position);
+ first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
+ first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
+ first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
+ first.GetCell(ColInProgress).SetCellFormula(
+ $"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
+
+ SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
+ SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
+
+ // «Срок отработки» — разница дат в днях, и только когда известны обе:
+ // иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
+ // дней (пустая ячейка — это 00.01.1900).
+ if (item.SubmitDate is not null && item.ClosingDate is not null)
+ {
+ var leadTimeCell = first.GetCell(ColLeadTime);
+ leadTimeCell.CellStyle = styles.DataLeadTime;
+ leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
+ }
+
+ first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
+ // Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
+ // отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
+ // заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
+ // в выгрузке.
+ first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
+ first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
+
+ // Построчные поля — каждое в свою строку.
+ for (var offset = 0; offset < lines.Count; offset++)
+ {
+ var row = sheet.GetRow(firstRowIdx + offset);
+ var line = lines[offset];
+
+ row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
+ SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
+ row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
+ }
+
+ // Объединяем общие колонки только когда строк действительно больше одной:
+ // область 1×1 Excel считает некорректной и портит файл.
+ if (lineCount > 1)
+ {
+ var lastRowIdx = firstRowIdx + lineCount - 1;
+ for (var col = 0; col < Columns.Length; col++)
+ {
+ if (PerLineColumns.Contains(col)) continue;
+ sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
+ }
+ }
+
+ return lineCount;
+ }
+
+ private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
+ {
+ var row = sheet.CreateRow(rowIdx);
+ var firstExcelRow = firstDataRowIdx + 1;
+ var lastExcelRow = lastDataRowIdx + 1;
+
+ for (var col = 0; col < Columns.Length; col++)
+ row.CreateCell(col).CellStyle = styles.Totals;
+
+ foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
+ {
+ var letter = ColLetter(col);
+ row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
+ }
+ }
+
+ ///
+ /// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
+ /// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
+ /// вместе с отфильтрованными строками.
+ ///
+ private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
+ {
+ // ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в
+ // отличие от чистого OOXML, где стиль колонки должен уступать явному
+ // стилю ячейки) переписывает им стиль уже существующих ячеек колонки
+ // целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия
+ // вакансии»/«Дата выхода» лишались синей заливки и получали формат даты
+ // вместо неё. Формат даты у самих дат и так стоит явно, ячейка за
+ // ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных,
+ // формат не унаследуют, но это не тот случай, ради которого стоит опять
+ // ломать шапку.
+ //
+ // Шапка закреплена — лист длинный, без этого при прокрутке не видно,
+ // какая колонка какая.
+ sheet.CreateFreezePane(0, HeaderRowIdx + 1);
+
+ if (lastDataRowIdx > HeaderRowIdx)
+ sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
+ }
+
+ private static void SetDate(ICell cell, Styles styles, DateOnly? date)
+ {
+ if (date is null) return;
+
+ cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
+ cell.CellStyle = styles.DataDate;
+ }
+
+ // ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ====================
+
+ ///
+ /// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт,
+ /// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого
+ /// рекрутёра в общем объёме — они же источник данных для двух круговых
+ /// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в
+ /// ручном отчёте HR.
+ ///
+ private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
+ {
+ var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName);
+ sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256));
+
+ var titleRow = sheet.CreateRow(0);
+ titleRow.CreateCell(0).SetCellValue("результаты");
+ titleRow.GetCell(0).CellStyle = styles.Title;
+
+ var summary = VacancyReportSummary.Build(report);
+ if (summary.Recruiters.Count == 0)
+ {
+ sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период.");
+ return;
+ }
+
+ var recruiters = summary.Recruiters;
+ for (var i = 0; i < recruiters.Count; i++)
+ sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256));
+
+ WriteHorizontalTable(sheet, styles, summary);
+
+ const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто»
+ var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ
+ var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1;
+ WriteVerticalTable(
+ sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal,
+ recruiters, r => r.ShareOfTotal(summary.GrandTotal));
+
+ var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1;
+ var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1;
+ WriteVerticalTable(
+ sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress,
+ recruiters, r => r.ShareOfInProgress(summary.GrandInProgress));
+
+ // Якоря диаграмм считаются НЕЗАВИСИМО от строк таблиц: у таблицы может
+ // быть 2 строки, а у диаграммы фиксированная высота ChartHeightRows — с
+ // якорем "на уровне шапки своей таблицы" вторая диаграмма при коротких
+ // таблицах вставала бы внутрь вертикального диапазона первой (см.
+ // ChartHeightRows). Обе выравнены по левому краю первой (горизонтальной)
+ // таблицы и просто идут подряд сверху вниз с отступом.
+ var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
+ var chart1TopRowIdx = HeaderRowIdx + 1;
+ var chart2TopRowIdx = chart1TopRowIdx + ChartHeightRows + 1;
+
+ CreatePieChart(
+ sheet, "Объём вакансий " + period,
+ firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx);
+ CreatePieChart(
+ sheet, "Объём вакансий в работе " + period,
+ firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx);
+ }
+
+ ///
+ /// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры
+ /// колонками, как в образце отчёта (там же W:Y).
+ ///
+ private static void WriteHorizontalTable(XSSFSheet sheet, Styles styles, VacancyReportSummary summary)
+ {
+ var headerRow = sheet.CreateRow(HeaderRowIdx + 1);
+ var totalRow = sheet.CreateRow(HeaderRowIdx + 2);
+ var inProgressRow = sheet.CreateRow(HeaderRowIdx + 3);
+ var closedRow = sheet.CreateRow(HeaderRowIdx + 4);
+
+ SetCell(headerRow, 0, "Кол-во вакансий", styles.SummaryHeaderYellow);
+ SetCell(totalRow, 0, "Всего", styles.SummaryLabel);
+ SetCell(inProgressRow, 0, "в работе (в%)", styles.SummaryRowGreenLabel);
+ SetCell(closedRow, 0, "закрыто", styles.SummaryLabel);
+
+ for (var i = 0; i < summary.Recruiters.Count; i++)
+ {
+ var r = summary.Recruiters[i];
+ var col = 1 + i;
+
+ SetCell(headerRow, col, r.Name, styles.SummaryHeaderYellow);
+ SetCell(totalRow, col, r.Total, styles.SummaryValue);
+ SetCell(inProgressRow, col, r.InProgressRate, styles.SummaryRowGreenPercent);
+ SetCell(closedRow, col, r.ClosedRate, styles.SummaryPercent);
+ }
+ }
+
+ ///
+ /// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого —
+ /// красным полужирным, как в образце) плюс по строке на рекрутёра с его
+ /// долей в этом итого. Возвращаемый диапазон строк — вход для круговой
+ /// диаграммы (см. CreatePieChart).
+ ///
+ private static void WriteVerticalTable(
+ XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total,
+ IReadOnlyList recruiters, Func share)
+ {
+ var headerRow = sheet.CreateRow(headerRowIdx);
+ SetCell(headerRow, 0, label, styles.SummaryBlueHeader);
+ SetCell(headerRow, 1, total, styles.SummaryBlueTotal);
+
+ // Заливка — на всю ширину блока (по числу рекрутёров, как в горизонтальной
+ // таблице выше), а не только под текстом заголовка: иначе шапка выглядит
+ // как две узкие цветные ячейки на фоне остального белого листа, а не как
+ // сплошная плашка над таблицей.
+ for (var col = 2; col <= recruiters.Count; col++)
+ SetCell(headerRow, col, string.Empty, styles.SummaryBlueHeader);
+
+ for (var i = 0; i < recruiters.Count; i++)
+ {
+ var row = sheet.CreateRow(headerRowIdx + 1 + i);
+ SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText);
+ SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent);
+ }
+ }
+
+ ///
+ /// Высота якоря диаграммы в строках — фиксированная и не зависит от числа
+ /// рекрутёров: подгонять размер круга под число категорий смысла нет, малое
+ /// число секторов на большом круге читается ничуть не хуже. Используется и
+ /// при размещении диаграмм (см. BuildSummarySheet — вторая идёт сразу под
+ /// первой, без наложения).
+ ///
+ private const int ChartHeightRows = 14;
+
+ /// Круговая диаграмма по одной вертикальной таблице: категории — колонка A (имена рекрутёров), значения — колонка B (доля).
+ private static void CreatePieChart(
+ XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx)
+ {
+ if (rowCount == 0) return;
+
+ var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch();
+ var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + ChartHeightRows);
+ var chart = (XSSFChart)drawing.CreateChart(anchor);
+ chart.SetTitle(title);
+
+ var lastDataRowIdx = firstDataRowIdx + rowCount - 1;
+ var data = chart.ChartDataFactory.CreatePieChartData();
+ var categories = DataSources.FromStringCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 0, 0));
+ var values = DataSources.FromNumericCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 1, 1));
+ data.AddSeries(categories, values);
+ chart.Plot(data);
+ chart.GetOrCreateLegend();
+ }
+
+ private static void SetCell(IRow row, int col, string value, ICellStyle style)
+ {
+ var cell = row.CreateCell(col);
+ cell.SetCellValue(value);
+ cell.CellStyle = style;
+ }
+
+ private static void SetCell(IRow row, int col, double value, ICellStyle style)
+ {
+ var cell = row.CreateCell(col);
+ cell.SetCellValue(value);
+ cell.CellStyle = style;
+ }
+
+ private static string ColLetter(int col0Based) => CellReference.ConvertNumToColString(col0Based);
+
+ ///
+ /// Все стили обоих листов — созданы один раз на книгу и переиспользуются:
+ /// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а
+ /// не собирается по кусочкам, поэтому под каждую итоговую комбинацию
+ /// заливка+шрифт+формат нужен отдельный объект стиля.
+ ///
+ private sealed class Styles
+ {
+ public ICellStyle Header { get; }
+ public ICellStyle GroupSeparator { get; }
+ public ICellStyle Data { get; }
+ public ICellStyle DataDate { get; }
+ public ICellStyle DataLeadTime { get; }
+ public ICellStyle Totals { get; }
+
+ public ICellStyle Title { get; }
+ public ICellStyle SummaryHeaderYellow { get; }
+ public ICellStyle SummaryLabel { get; }
+ public ICellStyle SummaryValue { get; }
+ public ICellStyle SummaryPercent { get; }
+ public ICellStyle SummaryRowGreenLabel { get; }
+ public ICellStyle SummaryRowGreenPercent { get; }
+ public ICellStyle SummaryBlueHeader { get; }
+ public ICellStyle SummaryBlueTotal { get; }
+ public ICellStyle SummaryGreenText { get; }
+ public ICellStyle SummaryGreenPercent { get; }
+
+ // Цвета — из образца отчёта (заливка шапки FFE5E5FF и т.д.), чтобы
+ // выгрузка выглядела привычно рядом со старым файлом.
+ private static readonly byte[] HeaderFill = [0xE5, 0xE5, 0xFF];
+ private static readonly byte[] GroupFill = [0xF2, 0xF2, 0xF2];
+ private static readonly byte[] YellowFill = [0xFF, 0xFF, 0x00];
+ private static readonly byte[] GreenFill = [0xC6, 0xE0, 0xB4];
+ private static readonly byte[] BlueFill = [0x4A, 0x7E, 0xBB];
+ private static readonly byte[] RedFont = [0xC0, 0x00, 0x00];
+ private static readonly byte[] WhiteFont = [0xFF, 0xFF, 0xFF];
+
+ public Styles(XSSFWorkbook workbook)
+ {
+ var colorMap = new DefaultIndexedColorMap();
+
+ ICellStyle New(
+ byte[]? fill = null, bool bold = false, byte[]? fontColor = null, string? numberFormat = null,
+ bool wrap = false, bool border = false, bool topDoubleBorder = false,
+ HorizontalAlignment? align = null, VerticalAlignment? valign = null)
+ {
+ var style = (XSSFCellStyle)workbook.CreateCellStyle();
+
+ if (fill is not null)
+ {
+ style.SetFillForegroundColor(new XSSFColor(fill, colorMap));
+ style.FillPattern = FillPattern.SolidForeground;
+ }
+ if (bold || fontColor is not null)
+ {
+ var font = (XSSFFont)workbook.CreateFont();
+ font.IsBold = bold;
+ if (fontColor is not null) font.SetColor(new XSSFColor(fontColor, colorMap));
+ style.SetFont(font);
+ }
+ if (numberFormat is not null)
+ style.DataFormat = workbook.CreateDataFormat().GetFormat(numberFormat);
+ if (wrap) style.WrapText = true;
+ if (border)
+ {
+ style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
+ }
+ if (topDoubleBorder) style.BorderTop = BorderStyle.Double;
+ if (align is not null) style.Alignment = align.Value;
+ if (valign is not null) style.VerticalAlignment = valign.Value;
+
+ return style;
+ }
+
+ Header = New(
+ fill: HeaderFill, bold: true, wrap: true, border: true,
+ align: HorizontalAlignment.Center, valign: VerticalAlignment.Center);
+ GroupSeparator = New(fill: GroupFill, bold: true);
+ Data = New(wrap: true, border: true);
+ DataDate = New(wrap: true, border: true, numberFormat: DateFormat);
+ DataLeadTime = New(wrap: true, border: true, numberFormat: "0");
+ Totals = New(bold: true, topDoubleBorder: true);
+
+ Title = New(bold: true);
+ SummaryHeaderYellow = New(fill: YellowFill, bold: true);
+ SummaryLabel = New(bold: true);
+ SummaryValue = New();
+ SummaryPercent = New(numberFormat: WholePercentFormat);
+ SummaryRowGreenLabel = New(fill: GreenFill, bold: true);
+ SummaryRowGreenPercent = New(fill: GreenFill, numberFormat: WholePercentFormat);
+ SummaryBlueHeader = New(fill: BlueFill, bold: true, fontColor: WhiteFont);
+ SummaryBlueTotal = New(fill: BlueFill, bold: true, fontColor: RedFont);
+ SummaryGreenText = New(fill: GreenFill);
+ SummaryGreenPercent = New(fill: GreenFill, numberFormat: PrecisePercentFormat);
+ }
+ }
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj
index bc84cb2..2451dac 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj
+++ b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj
@@ -4,6 +4,8 @@
+
+
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs
new file mode 100644
index 0000000..ee7e3d9
--- /dev/null
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs
@@ -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;
+
+///
+/// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза
+/// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и
+/// когда приняли).
+///
+/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
+/// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности
+/// дробные «1.0» у Number-колонок).
+///
+public sealed class VacancyReportServiceTests : IClassFixture
+{
+ 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();
+ }
+
+ // База у тестов класса общая, а сервис ищет тип заявки по 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);
+
+ /// Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST.
+ 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 }
+ };
+
+ /// Строка фазы «Исполнение» из списка «Заявка на подбор — согласование».
+ 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 }
+ };
+
+ ///
+ /// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в
+ /// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по
+ /// одному Id — в разных партициях Id повторяются.
+ ///
+ 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 SharePoint)> BuildAsync(
+ string name,
+ IReadOnlyList