From 24d2cd7a6abf24f1b3e9440d790484676ba612bf Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 18:01:24 +0500 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=20=D0=B2=20xlsx=20=D0=B7=D0=B0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=BE=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET api/reports/vacancies?from=&to= отдаёт лист «Список вакансий (все)» по образцу ручного отчёта HR: те же шестнадцать колонок, разделители по объектам, двойная нумерация, итоги формулами. Данные — из двух форм портала: «Заявка на подбор» даёт вакансию, «Приём на работу» — её закрытие (связь по SelectionRequestId + SelectionRequestListId). Рекрутёр и дата закрытия берутся из фазы «Исполнение» парного списка «— согласование»: в самой заявке их нет. Период отбирает заявки по «Дате подачи заявки» с откатом на Created — поле заполняется руками и часто пустое. Заявки на приём периодом не ограничены: приём по вакансии из начала интервала мог оформляться уже после его конца. Колонка «Комментарии» остаётся пустой — в ручном отчёте это пометки рекрутёра, которым в заявках пока негде храниться. Генерация — ClosedXML (MIT): нужны стили, формулы и autofilter, а EPPlus с v5 под Polyform Noncommercial. Co-Authored-By: Claude Opus 5 --- backend/Directory.Packages.props | 1 + .../Controllers/ReportsController.cs | 54 +++ backend/Tnsu.ApprovalRoute.Api/Program.cs | 2 + .../ApplicationServiceCollectionExtensions.cs | 2 + .../Reports/ExecutionRow.cs | 44 ++ .../Reports/HireLinkRow.cs | 35 ++ .../Reports/ReportsOptions.cs | 19 + .../Reports/SelectionRow.cs | 56 +++ .../Reports/SharePointJson.cs | 52 +++ .../Reports/VacancyReport.cs | 51 +++ .../Reports/VacancyReportService.cs | 358 ++++++++++++++++ .../Reports/VacancyReportWorkbook.cs | 224 ++++++++++ .../Tnsu.ApprovalRoute.Application.csproj | 2 + .../VacancyReportServiceTests.cs | 383 ++++++++++++++++++ .../VacancyReportWorkbookTests.cs | 161 ++++++++ 15 files changed, 1444 insertions(+) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/ExecutionRow.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/HireLinkRow.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/ReportsOptions.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/SelectionRow.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/SharePointJson.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index df56e95..78f2889 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -10,6 +10,7 @@ + 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..fc65e46 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs @@ -0,0 +1,51 @@ +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// +/// Отчёт по вакансиям за период — ровно то, что ложится на лист «Список вакансий +/// (все)» в xlsx: интервал плюс вакансии, разбитые по объектам/проектам. +/// +public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList Groups); + +/// +/// Объект/проект и его вакансии. Имя группы становится строкой-разделителем на +/// листе (как «КЭС КУРЧАТОВ» в образце отчёта); пустое имя — вакансии, у которых +/// объект в заявке не указан, они идут первым блоком без разделителя. +/// +public sealed record VacancyReportGroup(string Name, IReadOnlyList Rows); + +/// +/// Одна вакансия — строка листа. Колонки A/B (нумерация), G (в работе), J (срок +/// отработки) и итоги здесь не хранятся: их считает сам xlsx формулами, чтобы +/// цифры пересчитывались, если строку в файле поправят руками. +/// +public sealed record VacancyReportRow( + // C — «Вакансия»: должность из заявки на подбор. + string Position, + // D — «Подразделение» ответственного за подачу заявки. + string? Department, + // E — «Кол-во вакансий». + int VacanciesCount, + // F — «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор. + int ClosedCount, + // H — «Дата подачи заявки». + DateOnly? SubmitDate, + // I — «Дата закрытия вакансии»: дата решения по последнему шагу исполнения. + DateOnly? ClosingDate, + // K — «Дата выхода»: самая поздняя дата приёма среди принятых по этой вакансии. + DateOnly? StartDate, + // L — «Инициатор заявки». + string? Initiator, + // M — «Вместо сотрудника Ф.И.О.». + string? ReplacedEmployee, + // O — «Закрыта вакансия»: да / нет / частично. + string ClosedFlag, + // P — «Рекрутер»: исполнитель фазы исполнения заявки на подбор. + string? Recruiter); + +/// Значения колонки 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..9ab436a --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs @@ -0,0 +1,358 @@ +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). +/// +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 closed = hires.Count(x => x.Status != HireRequestStatus.Rejected); + + return new VacancyReportRow( + row.Position ?? string.Empty, + row.DepartmentName, + row.VacanciesCount, + closed, + row.SubmitDate, + execution.ClosingDate, + hires.Select(x => x.HireDate).Where(x => x is not null).Max(), + ShortName(row.Initiator), + ReplacedEmployee(row, hires), + ClosedFlag(row.VacanciesCount, closed), + execution.Recruiter); + } + + /// + /// «Вместо сотрудника» — сначала из заявки на приём (там это отдельное поле + /// «Взамен кого»), иначе из уточнения к причине открытия вакансии, и только + /// если причина — замена уволенного: при «новой штатной единице» в том же + /// поле лежит совсем другой текст. + /// + private static string? ReplacedEmployee(SelectionRow row, IReadOnlyList hires) + { + var fromHires = hires + .Select(x => x.ReplacedEmployee) + .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + + if (fromHires is not null) return fromHires; + + 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/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs new file mode 100644 index 0000000..b26e122 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -0,0 +1,224 @@ +using ClosedXML.Excel; + +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// +/// Отчёт по вакансиям в виде xlsx — лист «Список вакансий (все)» по образцу +/// ручного отчёта HR: те же шестнадцать колонок A…P в том же порядке, строки +/// разделители по объектам, итоги внизу. +/// +/// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное, +/// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой +/// пришлось бы править на каждое изменение состава вакансий. +/// +/// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся +/// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после +/// правки строки цифры должны пересчитаться сами. +/// +public static class VacancyReportWorkbook +{ + private const string SheetName = "Список вакансий (все)"; + + // Цвет шапки — из образца отчёта (fill FFE5E5FF), чтобы выгрузка выглядела + // привычно рядом со старым файлом. + private static readonly XLColor HeaderFill = XLColor.FromArgb(0xE5, 0xE5, 0xFF); + private static readonly XLColor GroupFill = XLColor.FromArgb(0xF2, 0xF2, 0xF2); + + private const string DateFormat = "dd.mm.yyyy"; + + /// Колонка листа: заголовок и ширина (ширины — из образца отчёта). + 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), + ("Инициатор заявки", 26.9), + ("Вместо сотрудника Ф.И.О.", 26.0), + ("Комментарии", 51.3), + ("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0), + ("Рекрутер", 23.9), + ]; + + // Номера колонок — именами, чтобы формулы и стили не разъезжались с Columns. + private const int ColTotalNumber = 1; + private const int ColGroupNumber = 2; + private const int ColPosition = 3; + private const int ColDepartment = 4; + private const int ColVacancies = 5; + private const int ColClosed = 6; + private const int ColInProgress = 7; + private const int ColSubmitDate = 8; + private const int ColClosingDate = 9; + private const int ColLeadTime = 10; + private const int ColStartDate = 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; + + private const int HeaderRow = 1; + + public static byte[] Build(VacancyReport report) + { + using var workbook = new XLWorkbook(); + var sheet = workbook.AddWorksheet(SheetName); + + WriteHeader(sheet); + + var row = HeaderRow + 1; + var totalNumber = 0; + var firstDataRow = row; + + foreach (var group in report.Groups) + { + if (group.Name.Length > 0) WriteGroupSeparator(sheet, row++, group.Name); + + var groupNumber = 0; + foreach (var item in group.Rows) + WriteRow(sheet, row++, ++totalNumber, ++groupNumber, item); + } + + var lastDataRow = row - 1; + + // Итоги — только если есть что суммировать: SUM по пустому диапазону + // (firstDataRow > lastDataRow) дал бы формулу с перевёрнутым диапазоном. + if (lastDataRow >= firstDataRow) WriteTotals(sheet, row, firstDataRow, lastDataRow); + + Finish(sheet, lastDataRow); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + + /// Имя файла — с периодом, как в ручном отчёте HR. + public static string FileName(VacancyReport report) => + $"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx"; + + private static void WriteHeader(IXLWorksheet sheet) + { + for (var i = 0; i < Columns.Length; i++) + { + var (title, width) = Columns[i]; + var column = i + 1; + + sheet.Cell(HeaderRow, column).Value = title; + sheet.Column(column).Width = width; + } + + var header = sheet.Range(HeaderRow, 1, HeaderRow, Columns.Length); + header.Style.Fill.BackgroundColor = HeaderFill; + header.Style.Font.Bold = true; + header.Style.Alignment.WrapText = true; + header.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; + header.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + header.Style.Border.OutsideBorder = XLBorderStyleValues.Thin; + header.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + + // Заголовки длинные и переносятся — без заданной высоты Excel показал бы + // одну строку из трёх (высота 90 — из образца отчёта). + sheet.Row(HeaderRow).Height = 90; + } + + /// + /// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в + /// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё + /// начинается заново — так же, как в ручном отчёте. + /// + private static void WriteGroupSeparator(IXLWorksheet sheet, int row, string name) + { + sheet.Cell(row, ColGroupNumber).Value = name; + + var range = sheet.Range(row, 1, row, Columns.Length); + range.Style.Fill.BackgroundColor = GroupFill; + range.Style.Font.Bold = true; + } + + private static void WriteRow(IXLWorksheet sheet, int row, int totalNumber, int groupNumber, VacancyReportRow item) + { + sheet.Cell(row, ColTotalNumber).Value = totalNumber; + sheet.Cell(row, ColGroupNumber).Value = groupNumber; + sheet.Cell(row, ColPosition).Value = item.Position; + sheet.Cell(row, ColDepartment).Value = item.Department ?? string.Empty; + sheet.Cell(row, ColVacancies).Value = item.VacanciesCount; + sheet.Cell(row, ColClosed).Value = item.ClosedCount; + sheet.Cell(row, ColInProgress).FormulaA1 = $"={Letter(ColVacancies)}{row}-{Letter(ColClosed)}{row}"; + + SetDate(sheet.Cell(row, ColSubmitDate), item.SubmitDate); + SetDate(sheet.Cell(row, ColClosingDate), item.ClosingDate); + + // «Срок отработки» — разница дат в днях, и только когда известны обе: + // иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч + // дней (пустая ячейка — это 00.01.1900). + if (item.SubmitDate is not null && item.ClosingDate is not null) + { + sheet.Cell(row, ColLeadTime).FormulaA1 = + $"={Letter(ColClosingDate)}{row}-{Letter(ColSubmitDate)}{row}"; + sheet.Cell(row, ColLeadTime).Style.NumberFormat.Format = "0"; + } + + SetDate(sheet.Cell(row, ColStartDate), item.StartDate); + + sheet.Cell(row, ColInitiator).Value = item.Initiator ?? string.Empty; + sheet.Cell(row, ColReplaced).Value = item.ReplacedEmployee ?? string.Empty; + // Колонка «Комментарии» остаётся пустой намеренно: в ручном отчёте это + // пометки рекрутёра («в подборе», «на стопе»), которым в заявках сейчас + // негде храниться. Колонку оставляем, чтобы её дописывали в выгрузке. + sheet.Cell(row, ColClosedFlag).Value = item.ClosedFlag; + sheet.Cell(row, ColRecruiter).Value = item.Recruiter ?? string.Empty; + + sheet.Range(row, 1, row, Columns.Length).Style.Alignment.WrapText = true; + sheet.Range(row, 1, row, Columns.Length).Style.Border.OutsideBorder = XLBorderStyleValues.Thin; + sheet.Range(row, 1, row, Columns.Length).Style.Border.InsideBorder = XLBorderStyleValues.Thin; + } + + private static void WriteTotals(IXLWorksheet sheet, int row, int firstDataRow, int lastDataRow) + { + foreach (var column in (int[])[ColVacancies, ColClosed, ColInProgress]) + sheet.Cell(row, column).FormulaA1 = + $"=SUM({Letter(column)}{firstDataRow}:{Letter(column)}{lastDataRow})"; + + var range = sheet.Range(row, 1, row, Columns.Length); + range.Style.Font.Bold = true; + range.Style.Border.TopBorder = XLBorderStyleValues.Double; + } + + /// + /// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её + /// захватывать не должен, иначе при любом фильтре итоги уезжают из вида + /// вместе с отфильтрованными строками. + /// + private static void Finish(IXLWorksheet sheet, int lastDataRow) + { + // Даты — по всей колонке, а не по заполненным ячейкам: формат должен + // остаться и у строк, которые допишут руками. + foreach (var column in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) + sheet.Column(column).Style.NumberFormat.Format = DateFormat; + + // Шапка закреплена — лист длинный, без этого при прокрутке не видно, + // какая колонка какая. + sheet.SheetView.FreezeRows(HeaderRow); + + if (lastDataRow > HeaderRow) + sheet.Range(HeaderRow, 1, lastDataRow, Columns.Length).SetAutoFilter(); + } + + private static void SetDate(IXLCell cell, DateOnly? date) + { + if (date is null) return; + + cell.Value = date.Value.ToDateTime(TimeOnly.MinValue); + cell.Style.NumberFormat.Format = DateFormat; + } + + private static string Letter(int column) => XLHelper.GetColumnLetterFromNumber(column); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index bc84cb2..feeede5 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..9b5d700 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs @@ -0,0 +1,383 @@ +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) => + new + { + Id = selectionId * 10, + RequestStatus = status, + SelectionRequestId = (double)selectionId, + SelectionRequestListId = selectionListUrl, + CandidateName = "Шакенов Анель", + ReplacedEmployee = replaced, + HireDate = hireDate + }; + + private async Task<(VacancyReportService Service, Mock SharePoint)> BuildAsync( + string name, + IReadOnlyList selections, + IReadOnlyList? executions = null, + IReadOnlyList? 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(); + + sharePoint + .Setup(x => x.GetItemsByListUrlAsync( + SiteUrl, selectionList, It.Is(q => q.Contains("OpenVacanciesCount")), + It.IsAny())) + .ReturnsAsync(selections.Select(Json).ToList()); + + sharePoint + .Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny())) + .ReturnsAsync(DataListTitle); + + sharePoint + .Setup(x => x.GetItemsByListTitleAsync( + SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("Исполнение")), + It.IsAny())) + .ReturnsAsync((executions ?? []).Select(Json).ToList()); + + sharePoint + .Setup(x => x.GetItemsByListUrlAsync( + SiteUrl, hireList, It.Is(q => q.Contains("SelectionRequestId")), + It.IsAny())) + .ReturnsAsync((hires ?? []).Select(Json).ToList()); + + var service = new VacancyReportService( + _services.GetRequiredService(), + sharePoint.Object, + _services.GetRequiredService(), + Options.Create(new ReportsOptions + { + RecruitingSelectionPageUrl = selectionPage, + RecruitingHirePageUrl = hirePage + }), + NullLogger.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.StartDate.ShouldBe(new DateOnly(2026, 8, 17)); + row.Initiator.ShouldBe("Ким Н.Ю."); + row.Recruiter.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); + row.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 StartDate_IsLatestHireDate() + { + 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-05T00:00:00Z"), + Hire(1, list, hireDate: "2026-08-25T00:00:00Z") + ]); + + var result = await service.GetAsync(From, To, CancellationToken.None); + + var row = SingleRow(result.Value); + row.ClosedCount.ShouldBe(2); + row.StartDate.ShouldBe(new DateOnly(2026, 8, 25)); + } + + [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); + + var rows = result.Value.Groups.ShouldHaveSingleItem().Rows; + rows.Single(r => r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик"); + rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull(); + } + + [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(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()), + Times.Once); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs new file mode 100644 index 0000000..753c13a --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs @@ -0,0 +1,161 @@ +using ClosedXML.Excel; +using Shouldly; +using Tnsu.ApprovalRoute.Application.Reports; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители +/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл +/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется +/// именно то, что увидит Excel. +/// +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 = "Сергеева Надежда Ивановна") => + new( + position, + "офис", + vacancies, + closed, + submitDate ?? new DateOnly(2026, 7, 8), + closingDate, + startDate, + "Ким Н.Ю.", + null, + closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes, + recruiter); + + private static IXLWorksheet Build(params VacancyReportGroup[] groups) + { + var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups); + var bytes = VacancyReportWorkbook.Build(report); + + using var stream = new MemoryStream(bytes); + // Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML + // держит уже разобранную модель в памяти, а не ленивую ссылку на поток. + var workbook = new XLWorkbook(stream); + return workbook.Worksheet("Список вакансий (все)"); + } + + [Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")] + public void Header_MatchesSample() + { + var sheet = Build(new VacancyReportGroup("", [Row("Логист")])); + + sheet.Cell(1, 2).GetString().ShouldBe("№"); + sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); + sheet.Cell(1, 4).GetString().ShouldBe("Подразделение"); + sheet.Cell(1, 5).GetString().ShouldBe("Кол-во вакансий"); + sheet.Cell(1, 8).GetString().ShouldBe("Дата подачи заявки"); + sheet.Cell(1, 9).GetString().ShouldBe("Дата закрытия вакансии"); + sheet.Cell(1, 11).GetString().ShouldBe("Дата выхода"); + sheet.Cell(1, 12).GetString().ShouldBe("Инициатор заявки"); + sheet.Cell(1, 14).GetString().ShouldBe("Комментарии"); + sheet.Cell(1, 16).GetString().ShouldBe("Рекрутер"); + sheet.Cell(1, 17).IsEmpty().ShouldBeTrue(); + } + + [Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")] + public void DataRow_Layout() + { + var sheet = Build(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)) + ])); + + sheet.Cell(2, 1).GetValue().ShouldBe(1); + sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела"); + sheet.Cell(2, 5).GetValue().ShouldBe(1); + sheet.Cell(2, 6).GetValue().ShouldBe(1); + sheet.Cell(2, 8).GetDateTime().ShouldBe(new DateTime(2026, 3, 30)); + sheet.Cell(2, 9).GetDateTime().ShouldBe(new DateTime(2026, 7, 8)); + sheet.Cell(2, 11).GetDateTime().ShouldBe(new DateTime(2026, 8, 17)); + sheet.Cell(2, 12).GetString().ShouldBe("Ким Н.Ю."); + // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. + sheet.Cell(2, 14).IsEmpty().ShouldBeTrue(); + sheet.Cell(2, 15).GetString().ShouldBe("да"); + sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна"); + } + + [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")] + public void DerivedColumns_AreFormulas() + { + var sheet = Build(new VacancyReportGroup("", [ + Row("Логист", vacancies: 3, closed: 1, + submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15)) + ])); + + sheet.Cell(2, 7).FormulaA1.ShouldBe("E2-F2"); + sheet.Cell(2, 10).FormulaA1.ShouldBe("I2-H2"); + } + + [Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")] + public void LeadTime_EmptyWithoutClosingDate() + { + var sheet = Build(new VacancyReportGroup("", [Row("Логист")])); + + sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty(); + sheet.Cell(2, 10).IsEmpty().ShouldBeTrue(); + } + + [Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")] + public void GroupSeparator_RestartsNumbering() + { + var sheet = Build( + new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]), + new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")])); + + // Верхний блок без объекта — сразу после шапки, без разделителя. + sheet.Cell(2, 3).GetString().ShouldBe("Логист"); + sheet.Cell(3, 3).GetString().ShouldBe("Юрист"); + + sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ"); + sheet.Cell(4, 3).IsEmpty().ShouldBeTrue(); + + // Сквозная нумерация продолжается, нумерация внутри объекта — с единицы. + sheet.Cell(5, 1).GetValue().ShouldBe(3); + sheet.Cell(5, 2).GetValue().ShouldBe(1); + sheet.Cell(6, 1).GetValue().ShouldBe(4); + sheet.Cell(6, 2).GetValue().ShouldBe(2); + } + + [Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")] + public void Totals_SumWholeDataRange() + { + var sheet = Build( + new VacancyReportGroup("", [Row("Логист")]), + new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")])); + + // Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке. + sheet.Cell(5, 5).FormulaA1.ShouldBe("SUM(E2:E4)"); + sheet.Cell(5, 6).FormulaA1.ShouldBe("SUM(F2:F4)"); + sheet.Cell(5, 7).FormulaA1.ShouldBe("SUM(G2:G4)"); + } + + [Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")] + public void EmptyReport_HeaderOnly() + { + var sheet = Build(); + + sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); + sheet.Cell(2, 5).IsEmpty().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"); + } +} -- 2.34.1 From 4dac12302c160559abf1f51f887388a7a8f46f21 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 11:12:45 +0500 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BB=D0=B8=D1=81=D1=82=20=C2=AB=D0=A0=D0=B5?= =?UTF-8?q?=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D1=8B=C2=BB=20=D1=81?= =?UTF-8?q?=20=D0=B4=D0=B8=D0=B0=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Второй лист xlsx по образцу ручного отчёта HR: горизонтальная таблица по рекрутёрам (всего/в работе %/закрыто %) и две вертикальные таблицы с долей каждого рекрутёра в общем объёме и в объёме "в работе" — плюс по круговой диаграмме на каждую (VacancyReportSummary, VacancyReportWorkbook.BuildSummarySheet). Библиотека для xlsx сменена с ClosedXML на NPOI: у ClosedXML нет поддержки Excel-диаграмм вовсе, только у NPOI из бесплатных вариантов она есть (XSSFChart/DataSources из NPOI.OOXML). Переписан весь генератор, тесты читают файл обратно тем же NPOI. Заодно запинена System.Security.Cryptography.Xml до 10.0.11 — версия, которую тянет NPOI транзитивно (8.0.2), с известной уязвимостью. Вакансии без рекрутёра сведены в «Без рекрутёра» и всегда идут в таблицах последними — не участник рейтинга, а технический хвост. Co-Authored-By: Claude Opus 5 --- backend/Directory.Packages.props | 8 +- .../Reports/VacancyReportSummary.cs | 70 +++ .../Reports/VacancyReportWorkbook.cs | 511 +++++++++++++----- .../Tnsu.ApprovalRoute.Application.csproj | 4 +- .../VacancyReportWorkbookTests.cs | 252 +++++++-- 5 files changed, 657 insertions(+), 188 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportSummary.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 78f2889..97274c9 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -10,7 +10,6 @@ - @@ -35,8 +34,15 @@ + + + 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 index b26e122..ea645e1 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -1,32 +1,39 @@ -using ClosedXML.Excel; +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 в том же порядке, строки -/// разделители по объектам, итоги внизу. +/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR: +/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по +/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя +/// круговыми диаграммами). /// -/// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное, -/// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой -/// пришлось бы править на каждое изменение состава вакансий. +/// Листы собираются кодом, а не заполнением файла-шаблона: число строк +/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) — +/// шаблон с такой структурой пришлось бы править на каждое изменение состава. /// -/// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся -/// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после -/// правки строки цифры должны пересчитаться сами. +/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов +/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный +/// лист выглядел бы просто как две таблицы без визуализации, как в образце. +/// +/// Производные колонки (G «в работе», J «срок отработки») и итоги первого +/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше +/// руками, и после правки строки цифры должны пересчитаться сами. /// public static class VacancyReportWorkbook { private const string SheetName = "Список вакансий (все)"; - - // Цвет шапки — из образца отчёта (fill FFE5E5FF), чтобы выгрузка выглядела - // привычно рядом со старым файлом. - private static readonly XLColor HeaderFill = XLColor.FromArgb(0xE5, 0xE5, 0xFF); - private static readonly XLColor GroupFill = XLColor.FromArgb(0xF2, 0xF2, 0xF2); + 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), @@ -47,56 +54,35 @@ public static class VacancyReportWorkbook ("Рекрутер", 23.9), ]; - // Номера колонок — именами, чтобы формулы и стили не разъезжались с Columns. - private const int ColTotalNumber = 1; - private const int ColGroupNumber = 2; - private const int ColPosition = 3; - private const int ColDepartment = 4; - private const int ColVacancies = 5; - private const int ColClosed = 6; - private const int ColInProgress = 7; - private const int ColSubmitDate = 8; - private const int ColClosingDate = 9; - private const int ColLeadTime = 10; - private const int ColStartDate = 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; + // Индексы колонок — 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 ColInitiator = 11; + private const int ColReplaced = 12; + private const int ColClosedFlag = 14; + private const int ColRecruiter = 15; - private const int HeaderRow = 1; + private const int HeaderRowIdx = 0; public static byte[] Build(VacancyReport report) { - using var workbook = new XLWorkbook(); - var sheet = workbook.AddWorksheet(SheetName); + using var workbook = new XSSFWorkbook(); + var styles = new Styles(workbook); - WriteHeader(sheet); - - var row = HeaderRow + 1; - var totalNumber = 0; - var firstDataRow = row; - - foreach (var group in report.Groups) - { - if (group.Name.Length > 0) WriteGroupSeparator(sheet, row++, group.Name); - - var groupNumber = 0; - foreach (var item in group.Rows) - WriteRow(sheet, row++, ++totalNumber, ++groupNumber, item); - } - - var lastDataRow = row - 1; - - // Итоги — только если есть что суммировать: SUM по пустому диапазону - // (firstDataRow > lastDataRow) дал бы формулу с перевёрнутым диапазоном. - if (lastDataRow >= firstDataRow) WriteTotals(sheet, row, firstDataRow, lastDataRow); - - Finish(sheet, lastDataRow); + BuildVacancyListSheet(workbook, styles, report); + BuildSummarySheet(workbook, styles, report); using var stream = new MemoryStream(); - workbook.SaveAs(stream); + workbook.Write(stream, leaveOpen: true); return stream.ToArray(); } @@ -104,29 +90,51 @@ public static class VacancyReportWorkbook public static string FileName(VacancyReport report) => $"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx"; - private static void WriteHeader(IXLWorksheet sheet) - { - for (var i = 0; i < Columns.Length; i++) - { - var (title, width) = Columns[i]; - var column = i + 1; + // ==================== Лист 1: «Список вакансий (все)» ==================== - sheet.Cell(HeaderRow, column).Value = title; - sheet.Column(column).Width = width; + 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) + WriteRow(sheet, styles, rowIdx++, ++totalNumber, ++groupNumber, item); } - var header = sheet.Range(HeaderRow, 1, HeaderRow, Columns.Length); - header.Style.Fill.BackgroundColor = HeaderFill; - header.Style.Font.Bold = true; - header.Style.Alignment.WrapText = true; - header.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center; - header.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; - header.Style.Border.OutsideBorder = XLBorderStyleValues.Thin; - header.Style.Border.InsideBorder = XLBorderStyleValues.Thin; + 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 — из образца отчёта). - sheet.Row(HeaderRow).Height = 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)); + } } /// @@ -134,91 +142,340 @@ public static class VacancyReportWorkbook /// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё /// начинается заново — так же, как в ручном отчёте. /// - private static void WriteGroupSeparator(IXLWorksheet sheet, int row, string name) + private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name) { - sheet.Cell(row, ColGroupNumber).Value = name; + var row = sheet.CreateRow(rowIdx); + for (var col = 0; col < Columns.Length; col++) + row.CreateCell(col).CellStyle = styles.GroupSeparator; - var range = sheet.Range(row, 1, row, Columns.Length); - range.Style.Fill.BackgroundColor = GroupFill; - range.Style.Font.Bold = true; + row.GetCell(ColGroupNumber).SetCellValue(name); } - private static void WriteRow(IXLWorksheet sheet, int row, int totalNumber, int groupNumber, VacancyReportRow item) + private static void WriteRow( + XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item) { - sheet.Cell(row, ColTotalNumber).Value = totalNumber; - sheet.Cell(row, ColGroupNumber).Value = groupNumber; - sheet.Cell(row, ColPosition).Value = item.Position; - sheet.Cell(row, ColDepartment).Value = item.Department ?? string.Empty; - sheet.Cell(row, ColVacancies).Value = item.VacanciesCount; - sheet.Cell(row, ColClosed).Value = item.ClosedCount; - sheet.Cell(row, ColInProgress).FormulaA1 = $"={Letter(ColVacancies)}{row}-{Letter(ColClosed)}{row}"; + var row = sheet.CreateRow(rowIdx); + var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки. - SetDate(sheet.Cell(row, ColSubmitDate), item.SubmitDate); - SetDate(sheet.Cell(row, ColClosingDate), item.ClosingDate); + for (var col = 0; col < Columns.Length; col++) + row.CreateCell(col).CellStyle = styles.Data; + + row.GetCell(ColTotalNumber).SetCellValue(totalNumber); + row.GetCell(ColGroupNumber).SetCellValue(groupNumber); + row.GetCell(ColPosition).SetCellValue(item.Position); + row.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty); + row.GetCell(ColVacancies).SetCellValue(item.VacanciesCount); + row.GetCell(ColClosed).SetCellValue(item.ClosedCount); + row.GetCell(ColInProgress).SetCellFormula($"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}"); + + SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate); + SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate); // «Срок отработки» — разница дат в днях, и только когда известны обе: // иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч // дней (пустая ячейка — это 00.01.1900). if (item.SubmitDate is not null && item.ClosingDate is not null) { - sheet.Cell(row, ColLeadTime).FormulaA1 = - $"={Letter(ColClosingDate)}{row}-{Letter(ColSubmitDate)}{row}"; - sheet.Cell(row, ColLeadTime).Style.NumberFormat.Format = "0"; + var leadTimeCell = row.GetCell(ColLeadTime); + leadTimeCell.CellStyle = styles.DataLeadTime; + leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}"); } - SetDate(sheet.Cell(row, ColStartDate), item.StartDate); + SetDate(row.GetCell(ColStartDate), styles, item.StartDate); - sheet.Cell(row, ColInitiator).Value = item.Initiator ?? string.Empty; - sheet.Cell(row, ColReplaced).Value = item.ReplacedEmployee ?? string.Empty; - // Колонка «Комментарии» остаётся пустой намеренно: в ручном отчёте это - // пометки рекрутёра («в подборе», «на стопе»), которым в заявках сейчас - // негде храниться. Колонку оставляем, чтобы её дописывали в выгрузке. - sheet.Cell(row, ColClosedFlag).Value = item.ClosedFlag; - sheet.Cell(row, ColRecruiter).Value = item.Recruiter ?? string.Empty; - - sheet.Range(row, 1, row, Columns.Length).Style.Alignment.WrapText = true; - sheet.Range(row, 1, row, Columns.Length).Style.Border.OutsideBorder = XLBorderStyleValues.Thin; - sheet.Range(row, 1, row, Columns.Length).Style.Border.InsideBorder = XLBorderStyleValues.Thin; + row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty); + row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty); + // Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в + // ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым + // в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали + // в выгрузке. + row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag); + row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty); } - private static void WriteTotals(IXLWorksheet sheet, int row, int firstDataRow, int lastDataRow) + private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx) { - foreach (var column in (int[])[ColVacancies, ColClosed, ColInProgress]) - sheet.Cell(row, column).FormulaA1 = - $"=SUM({Letter(column)}{firstDataRow}:{Letter(column)}{lastDataRow})"; + var row = sheet.CreateRow(rowIdx); + var firstExcelRow = firstDataRowIdx + 1; + var lastExcelRow = lastDataRowIdx + 1; - var range = sheet.Range(row, 1, row, Columns.Length); - range.Style.Font.Bold = true; - range.Style.Border.TopBorder = XLBorderStyleValues.Double; + 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})"); + } } /// - /// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её - /// захватывать не должен, иначе при любом фильтре итоги уезжают из вида + /// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр + /// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида /// вместе с отфильтрованными строками. /// - private static void Finish(IXLWorksheet sheet, int lastDataRow) + private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx) { - // Даты — по всей колонке, а не по заполненным ячейкам: формат должен - // остаться и у строк, которые допишут руками. - foreach (var column in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) - sheet.Column(column).Style.NumberFormat.Format = DateFormat; + // Даты — дефолтным стилем колонки, а не только по заполненным ячейкам: + // формат должен остаться и у строк, которые допишут руками. + foreach (var col in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) + sheet.SetDefaultColumnStyle(col, styles.DataDate); // Шапка закреплена — лист длинный, без этого при прокрутке не видно, // какая колонка какая. - sheet.SheetView.FreezeRows(HeaderRow); + sheet.CreateFreezePane(0, HeaderRowIdx + 1); - if (lastDataRow > HeaderRow) - sheet.Range(HeaderRow, 1, lastDataRow, Columns.Length).SetAutoFilter(); + if (lastDataRowIdx > HeaderRowIdx) + sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1)); } - private static void SetDate(IXLCell cell, DateOnly? date) + private static void SetDate(ICell cell, Styles styles, DateOnly? date) { if (date is null) return; - cell.Value = date.Value.ToDateTime(TimeOnly.MinValue); - cell.Style.NumberFormat.Format = DateFormat; + cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue)); + cell.CellStyle = styles.DataDate; } - private static string Letter(int column) => XLHelper.GetColumnLetterFromNumber(column); + // ==================== Лист 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)); + + var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}"; + CreatePieChart( + sheet, "Объём вакансий " + period, + firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable1HeaderRowIdx); + CreatePieChart( + sheet, "Объём вакансий в работе " + period, + firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable2HeaderRowIdx); + } + + /// + /// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры + /// колонками, как в образце отчёта (там же 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 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); + } + } + + /// + /// Круговая диаграмма по одной вертикальной таблице: категории — колонка A + /// (имена рекрутёров), значения — колонка B (доля). Высота якоря — + /// фиксированные 14 строк: чтения диаграммы ради нет смысла подгонять её + /// размер под число рекрутёров, малое число категорий на большом круге + /// читается ничуть не хуже. + /// + 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 + 14); + 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 feeede5..2451dac 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -4,8 +4,8 @@ - - + + diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs index 753c13a..1b6bd2c 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs @@ -1,14 +1,16 @@ -using ClosedXML.Excel; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; using Shouldly; using Tnsu.ApprovalRoute.Application.Reports; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// -/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители -/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл -/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется -/// именно то, что увидит Excel. +/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок, +/// строки-разделители по объектам, двойная нумерация (сквозная + внутри +/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё +/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается +/// обратно тем же NPOI — проверяется именно то, что увидит Excel. /// public sealed class VacancyReportWorkbookTests { @@ -33,122 +35,147 @@ public sealed class VacancyReportWorkbookTests closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes, recruiter); - private static IXLWorksheet Build(params VacancyReportGroup[] groups) + private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups) { - var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups); + var report = new VacancyReport(from, to, groups); var bytes = VacancyReportWorkbook.Build(report); + return new XSSFWorkbook(new MemoryStream(bytes)); + } - using var stream = new MemoryStream(bytes); - // Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML - // держит уже разобранную модель в памяти, а не ленивую ссылку на поток. - var workbook = new XLWorkbook(stream); - return workbook.Worksheet("Список вакансий (все)"); + private static ISheet BuildListSheet(params VacancyReportGroup[] groups) => + BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups) + .GetSheet("Список вакансий (все)"); + + /// Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E». + 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; + } + + /// Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе). + 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 = Build(new VacancyReportGroup("", [Row("Логист")])); + var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); - sheet.Cell(1, 2).GetString().ShouldBe("№"); - sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); - sheet.Cell(1, 4).GetString().ShouldBe("Подразделение"); - sheet.Cell(1, 5).GetString().ShouldBe("Кол-во вакансий"); - sheet.Cell(1, 8).GetString().ShouldBe("Дата подачи заявки"); - sheet.Cell(1, 9).GetString().ShouldBe("Дата закрытия вакансии"); - sheet.Cell(1, 11).GetString().ShouldBe("Дата выхода"); - sheet.Cell(1, 12).GetString().ShouldBe("Инициатор заявки"); - sheet.Cell(1, 14).GetString().ShouldBe("Комментарии"); - sheet.Cell(1, 16).GetString().ShouldBe("Рекрутер"); - sheet.Cell(1, 17).IsEmpty().ShouldBeTrue(); + 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, 14).ShouldBe("Комментарии"); + StringAt(sheet, 1, 16).ShouldBe("Рекрутер"); + IsEmptyAt(sheet, 1, 17).ShouldBeTrue(); } [Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")] public void DataRow_Layout() { - var sheet = Build(new VacancyReportGroup("", [ + 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)) ])); - sheet.Cell(2, 1).GetValue().ShouldBe(1); - sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела"); - sheet.Cell(2, 5).GetValue().ShouldBe(1); - sheet.Cell(2, 6).GetValue().ShouldBe(1); - sheet.Cell(2, 8).GetDateTime().ShouldBe(new DateTime(2026, 3, 30)); - sheet.Cell(2, 9).GetDateTime().ShouldBe(new DateTime(2026, 7, 8)); - sheet.Cell(2, 11).GetDateTime().ShouldBe(new DateTime(2026, 8, 17)); - sheet.Cell(2, 12).GetString().ShouldBe("Ким Н.Ю."); + 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, 12).ShouldBe("Ким Н.Ю."); // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. - sheet.Cell(2, 14).IsEmpty().ShouldBeTrue(); - sheet.Cell(2, 15).GetString().ShouldBe("да"); - sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна"); + IsEmptyAt(sheet, 2, 14).ShouldBeTrue(); + StringAt(sheet, 2, 15).ShouldBe("да"); + StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна"); } [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")] public void DerivedColumns_AreFormulas() { - var sheet = Build(new VacancyReportGroup("", [ + var sheet = BuildListSheet(new VacancyReportGroup("", [ Row("Логист", vacancies: 3, closed: 1, submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15)) ])); - sheet.Cell(2, 7).FormulaA1.ShouldBe("E2-F2"); - sheet.Cell(2, 10).FormulaA1.ShouldBe("I2-H2"); + 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 = Build(new VacancyReportGroup("", [Row("Логист")])); + var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); - sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty(); - sheet.Cell(2, 10).IsEmpty().ShouldBeTrue(); + FormulaAt(sheet, 2, 10).ShouldBeEmpty(); + IsEmptyAt(sheet, 2, 10).ShouldBeTrue(); } [Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")] public void GroupSeparator_RestartsNumbering() { - var sheet = Build( + var sheet = BuildListSheet( new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")])); // Верхний блок без объекта — сразу после шапки, без разделителя. - sheet.Cell(2, 3).GetString().ShouldBe("Логист"); - sheet.Cell(3, 3).GetString().ShouldBe("Юрист"); + StringAt(sheet, 2, 3).ShouldBe("Логист"); + StringAt(sheet, 3, 3).ShouldBe("Юрист"); - sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ"); - sheet.Cell(4, 3).IsEmpty().ShouldBeTrue(); + StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ"); + IsEmptyAt(sheet, 4, 3).ShouldBeTrue(); // Сквозная нумерация продолжается, нумерация внутри объекта — с единицы. - sheet.Cell(5, 1).GetValue().ShouldBe(3); - sheet.Cell(5, 2).GetValue().ShouldBe(1); - sheet.Cell(6, 1).GetValue().ShouldBe(4); - sheet.Cell(6, 2).GetValue().ShouldBe(2); + 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 = Build( + var sheet = BuildListSheet( new VacancyReportGroup("", [Row("Логист")]), new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")])); // Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке. - sheet.Cell(5, 5).FormulaA1.ShouldBe("SUM(E2:E4)"); - sheet.Cell(5, 6).FormulaA1.ShouldBe("SUM(F2:F4)"); - sheet.Cell(5, 7).FormulaA1.ShouldBe("SUM(G2:G4)"); + 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 = Build(); + var sheet = BuildListSheet(); - sheet.Cell(1, 3).GetString().ShouldBe("Вакансия"); - sheet.Cell(2, 5).IsEmpty().ShouldBeTrue(); + StringAt(sheet, 1, 3).ShouldBe("Вакансия"); + IsEmptyAt(sheet, 2, 5).ShouldBeTrue(); } [Fact(DisplayName = "Имя файла — с периодом отчёта")] @@ -158,4 +185,113 @@ public sealed class VacancyReportWorkbookTests 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(); + } } -- 2.34.1 From 80973bc1c09c614a3a681801886881b491d17668 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 11:17:07 +0500 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=D0=B4=D0=B8=D0=B0=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=BC=D1=8B=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F,=20=D1=88=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=BF=D0=BB=D0=BE=D1=88=D0=BD=D0=B0=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Якорь второй диаграммы стоял на строке заголовка второй вертикальной таблицы — при коротком списке рекрутёров (2-4 человека) это оказывалось внутри вертикального диапазона первой диаграммы (фиксированные 14 строк), и они наезжали друг на друга. Теперь обе диаграммы позиционируются независимо от строк таблиц: вторая всегда сразу под первой (ChartHeightRows). Заливка шапки «Общий объем работ»/«Общий объем в работе» теперь на всю ширину блока (по числу рекрутёров), а не только под текстом — иначе она выглядела как две узкие цветные ячейки на белом фоне. Co-Authored-By: Claude Opus 5 --- .../Reports/VacancyReportWorkbook.cs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs index ea645e1..c950894 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -281,13 +281,22 @@ public static class VacancyReportWorkbook 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: vertTable1HeaderRowIdx); + firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx); CreatePieChart( sheet, "Объём вакансий в работе " + period, - firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable2HeaderRowIdx); + firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx); } /// @@ -332,6 +341,13 @@ public static class VacancyReportWorkbook 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); @@ -341,19 +357,22 @@ public static class VacancyReportWorkbook } /// - /// Круговая диаграмма по одной вертикальной таблице: категории — колонка A - /// (имена рекрутёров), значения — колонка B (доля). Высота якоря — - /// фиксированные 14 строк: чтения диаграммы ради нет смысла подгонять её - /// размер под число рекрутёров, малое число категорий на большом круге - /// читается ничуть не хуже. + /// Высота якоря диаграммы в строках — фиксированная и не зависит от числа + /// рекрутёров: подгонять размер круга под число категорий смысла нет, малое + /// число секторов на большом круге читается ничуть не хуже. Используется и + /// при размещении диаграмм (см. 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 + 14); + var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + ChartHeightRows); var chart = (XSSFChart)drawing.CreateChart(anchor); chart.SetTitle(title); -- 2.34.1 From 1221b31225183484e99c4a2a1fe5c4298c08d55f Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 11:33:13 +0500 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=20=D0=B4=D0=B0=D1=82=D0=B0=D0=BC=D0=B8=20=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D0=BB=D0=B8=20=D0=B7=D0=B0=D0=BB=D0=B8=D0=B2?= =?UTF-8?q?=D0=BA=D1=83=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20(SetDefaultColu?= =?UTF-8?q?mnStyle=20=D0=B2=20NPOI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1/I1/K1 («Дата подачи заявки», «Дата закрытия вакансии», «Дата выхода») выходили с форматом даты вместо синей заливки шапки — хотя код применял ко всей строке 1 один и тот же стиль Header. Причина — SetDefaultColumnStyle(col, DataDate), вызванный ПОСЛЕ записи строк: в отличие от чистого OOXML, где стиль колонки уступает явному стилю ячейки, NPOI этим вызовом переписывает стиль уже существующих ячеек колонки целиком, включая шапку. Формат даты и так стоит на каждой конкретной ячейке с датой (SetDate) — вызов просто убран. Добавлен регрессионный тест (сравнение Index стиля, а не ссылки на объект — NPOI отдаёт новую обёртку ICellStyle на каждый вызов CellStyle, ссылки не совпадают даже у идентичного стиля). Co-Authored-By: Claude Opus 5 --- .../Reports/VacancyReportWorkbook.cs | 15 ++++++++++----- .../VacancyReportWorkbookTests.cs | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs index c950894..99ee23a 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -216,11 +216,16 @@ public static class VacancyReportWorkbook /// private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx) { - // Даты — дефолтным стилем колонки, а не только по заполненным ячейкам: - // формат должен остаться и у строк, которые допишут руками. - foreach (var col in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) - sheet.SetDefaultColumnStyle(col, styles.DataDate); - + // ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в + // отличие от чистого OOXML, где стиль колонки должен уступать явному + // стилю ячейки) переписывает им стиль уже существующих ячеек колонки + // целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия + // вакансии»/«Дата выхода» лишались синей заливки и получали формат даты + // вместо неё. Формат даты у самих дат и так стоит явно, ячейка за + // ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных, + // формат не унаследуют, но это не тот случай, ради которого стоит опять + // ломать шапку. + // // Шапка закреплена — лист длинный, без этого при прокрутке не видно, // какая колонка какая. sheet.CreateFreezePane(0, HeaderRowIdx + 1); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs index 1b6bd2c..6861295 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs @@ -90,6 +90,23 @@ public sealed class VacancyReportWorkbookTests IsEmptyAt(sheet, 1, 17).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() { -- 2.34.1 From c0bc73559e29bf20f969711b5e4839dbf9488779 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 14:10:15 +0500 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=D0=BF=D0=BE=D1=87=D0=B5=D0=BC?= =?UTF-8?q?=D1=83=20=D0=BE=D1=82=D1=87=D1=91=D1=82=20=D0=BF=D0=BE=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=BA=D0=B0=D0=BD=D1=81=D0=B8=D1=8F=D0=BC=20=D0=BD=D0=B0?= =?UTF-8?q?=20items=20REST,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BD=D0=B0=20Searc?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбирались, можно ли перевести чтение на Search: один KQL сразу по всем партициям, отставание индекса отчёту не критично. На тенанте проверено поштучным сравнением Search и REST — часть нужных полей индекс не отдаёт вообще, хотя значения в списках есть: • Заявка на подбор: OpenReason, OpenReasonDetails • Прием на работу: CandidateName, HireDate, ReplacedEmployee • — согласование: Ticket (Lookup — managed property не нашлась вовсе) Префикс группы колонок сайта (TansuSite…) не помогает, задержка crawl тоже ни при чём: у тех же свежих элементов рабочие поля Search отдаёт сразу. Значит эти поля пришлось бы дочитывать построчно батчами по 100, и на 1000 заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST. В list view threshold REST не упирается by design: партиции режутся на 4900 (ListPartitioning:ItemCountThreshold), ни один $filter не сканирует больше предела. Кода не меняем — фиксируем разбор в doc-комментарии, чтобы вопрос не поднимался заново. Вернуться к Search осмысленно только после того, как в Search Schema заведут работающие managed properties для этих полей. Co-Authored-By: Claude Opus 5 --- .../Reports/VacancyReportService.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs index 9ab436a..e24e071 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs @@ -18,6 +18,27 @@ namespace Tnsu.ApprovalRoute.Application.Reports; /// /// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного /// списка «— согласование», из строк фазы «Исполнение» (см. 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) тоже не +/// помогает — пробовали. +/// +/// Значит эти поля пришлось бы дочитывать построчно батчами по 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, -- 2.34.1 From 4cd5ccd97c43da7201cfe99f7368ce51b2467366 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 14:22:37 +0500 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=D0=BA=D0=BE=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=B2=D0=B0=D1=8F=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?,=20=D0=BF=D0=BE=D1=87=D0=B5=D0=BC=D1=83=20managed=20properties?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=B4=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Одноимённые колонки в РАЗНЫХ списках — разные поля со своими GUID, значит и со своими crawled property (ows_Position, ows_Position1, …); автосозданное managed property привязано только к одному из них. Отсюда PositionOWSTEXT, который отдаёт значения в «Заявка на подбор — данные» и молчит в «Прием на работу — данные» при одном и том же имени. Проверять надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим занимается новая команда `sp-write props`. Co-Authored-By: Claude Opus 5 --- .../Reports/VacancyReportService.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs index e24e071..cbfcebd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs @@ -31,6 +31,15 @@ namespace Tnsu.ApprovalRoute.Application.Reports; /// Префикс группы колонок сайта (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: партиции -- 2.34.1 From 240e5984b1a506cae6aedbf132801f99a2729c15 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 31 Aug 2026 14:29:13 +0500 Subject: [PATCH 7/7] =?UTF-8?q?feat(=D0=BE=D1=82=D1=87=D1=91=D1=82):=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B6=D0=B4=D1=8B=D0=B9=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC?= =?UTF-8?q?=20+=20=D1=81=D1=82=D0=BE=D0=BB=D0=B1=D0=B5=D1=86=20=C2=AB?= =?UTF-8?q?=D0=A4.=D0=98.=D0=9E.=20=D0=BD=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BE=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На одну заявку на подбор приходится несколько заявок на приём («Разнорабочий, 4 вакансии» → 4 кандидата), и раньше они схлопывались в одну строку: ФИО не было вовсе, а «Дата выхода» показывала только САМУЮ ПОЗДНЮЮ из четырёх. Кто именно вышел и когда — из отчёта было не узнать. Теперь вакансия занимает столько строк, сколько по ней приёмов. Построчные колонки — «Дата выхода», новая «Ф.И.О. нанимаемого сотрудника» и «Вместо сотрудника Ф.И.О.» (у каждого приёма своё «взамен кого»); все остальные общие и объединяются по вертикали. Модель: VacancyReportRow.Lines — именно СТРОКИ ЛИСТА, а не «приёмы». У вакансии без приёмов строка всё равно одна (иначе она пропала бы из отчёта), с пустыми ФИО и датой, но с «Вместо сотрудника» из причины открытия вакансии — прежнее поведение сохранено. Реальное число приёмов осталось в ClosedCount. Отклонённые приёмы теперь отбрасываются целиком, а не только из счётчика: отдельная строка с ФИО отклонённого кандидата утверждала бы «вот кого приняли». Объединение заводится только при lineCount > 1 — область 1×1 Excel считает некорректной и портит файл. Значение общих колонок пишется ТОЛЬКО в первую строку: у объединённой области Excel хранит его в верхней левой ячейке, а дубли сломали бы SUM в итогах (посчитал бы вакансию по разу на каждый приём) — на это есть отдельный тест. Co-Authored-By: Claude Opus 5 --- .../Reports/VacancyReport.cs | 55 ++++++--- .../Reports/VacancyReportService.cs | 56 ++++++--- .../Reports/VacancyReportWorkbook.cs | 111 +++++++++++++----- .../VacancyReportServiceTests.cs | 70 +++++++++-- .../VacancyReportWorkbookTests.cs | 98 ++++++++++++++-- 5 files changed, 302 insertions(+), 88 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs index fc65e46..ead3c19 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs @@ -14,33 +14,54 @@ public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList Rows); /// -/// Одна вакансия — строка листа. Колонки A/B (нумерация), G (в работе), J (срок -/// отработки) и итоги здесь не хранятся: их считает сам xlsx формулами, чтобы -/// цифры пересчитывались, если строку в файле поправят руками. +/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк +/// на листе, там они объединяются по вертикали; то, что различается от приёма к +/// приёму, лежит в Lines. +/// +/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся: +/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в +/// файле поправят руками. /// public sealed record VacancyReportRow( - // C — «Вакансия»: должность из заявки на подбор. + // «Вакансия» — должность из заявки на подбор. string Position, - // D — «Подразделение» ответственного за подачу заявки. + // «Подразделение» ответственного за подачу заявки. string? Department, - // E — «Кол-во вакансий». + // «Кол-во вакансий». int VacanciesCount, - // F — «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор. + // «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор. int ClosedCount, - // H — «Дата подачи заявки». + // «Дата подачи заявки». DateOnly? SubmitDate, - // I — «Дата закрытия вакансии»: дата решения по последнему шагу исполнения. + // «Дата закрытия вакансии»: дата решения по последнему шагу исполнения. DateOnly? ClosingDate, - // K — «Дата выхода»: самая поздняя дата приёма среди принятых по этой вакансии. - DateOnly? StartDate, - // L — «Инициатор заявки». + // «Инициатор заявки». string? Initiator, - // M — «Вместо сотрудника Ф.И.О.». - string? ReplacedEmployee, - // O — «Закрыта вакансия»: да / нет / частично. + // «Закрыта вакансия»: да / нет / частично. string ClosedFlag, - // P — «Рекрутер»: исполнитель фазы исполнения заявки на подбор. - string? Recruiter); + // «Рекрутер»: исполнитель фазы исполнения заявки на подбор. + string? Recruiter, + // Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine). + IReadOnlyList Lines); + +/// +/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками. +/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии» +/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и +/// свой «взамен кого». +/// +/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все +/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с +/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число +/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется. +/// +public sealed record VacancyReportLine( + // «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём. + string? CandidateName, + // «Дата выхода» — дата приёма этого кандидата. + DateOnly? StartDate, + // «Вместо сотрудника Ф.И.О.». + string? ReplacedEmployee); /// Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет. public static class VacancyClosedFlags diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs index cbfcebd..a03dc40 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs @@ -227,37 +227,59 @@ public sealed class VacancyReportService( ? found : []; - // Закрытыми считаем места, на которые реально оформили приём: отклонённая - // заявка на приём вакансию не закрывает — кандидат по ней не выходит. - var closed = hires.Count(x => x.Status != HireRequestStatus.Rejected); + // Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой + // кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы + // ложью («вот кого приняли»). + var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList(); return new VacancyReportRow( row.Position ?? string.Empty, row.DepartmentName, row.VacanciesCount, - closed, + accepted.Count, row.SubmitDate, execution.ClosingDate, - hires.Select(x => x.HireDate).Where(x => x is not null).Max(), ShortName(row.Initiator), - ReplacedEmployee(row, hires), - ClosedFlag(row.VacanciesCount, closed), - execution.Recruiter); + ClosedFlag(row.VacanciesCount, accepted.Count), + execution.Recruiter, + Lines(row, accepted)); } /// - /// «Вместо сотрудника» — сначала из заявки на приём (там это отдельное поле - /// «Взамен кого»), иначе из уточнения к причине открытия вакансии, и только - /// если причина — замена уволенного: при «новой штатной единице» в том же - /// поле лежит совсем другой текст. + /// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет + /// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ + /// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней + /// заполняется из причины открытия вакансии. /// - private static string? ReplacedEmployee(SelectionRow row, IReadOnlyList hires) + private static IReadOnlyList Lines(SelectionRow row, IReadOnlyList accepted) { - var fromHires = hires - .Select(x => x.ReplacedEmployee) - .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + if (accepted.Count == 0) + return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))]; - if (fromHires is not null) return fromHires; + return accepted + // Порядок строк внутри вакансии — по дате выхода: так в отчёте видно, + // кто вышел раньше. Приём без даты уходит в конец. + .OrderBy(x => x.HireDate ?? DateOnly.MaxValue) + .ThenBy(x => x.CandidateName, StringComparer.CurrentCulture) + .Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x))) + .ToList(); + } + + /// + /// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле + /// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если + /// причина — замена уволенного: при «новой штатной единице» в том же поле + /// лежит совсем другой текст. + /// + /// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии + /// он одинаковый — это нормально: причина открытия у вакансии одна. + /// + /// 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; } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs index 99ee23a..82e6539 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -47,6 +47,7 @@ public static class VacancyReportWorkbook ("Дата закрытия вакансии", 14.9), ("Срок отработки", 12.3), ("Дата выхода", 13.9), + ("Ф.И.О. нанимаемого сотрудника", 30.0), ("Инициатор заявки", 26.9), ("Вместо сотрудника Ф.И.О.", 26.0), ("Комментарии", 51.3), @@ -66,10 +67,19 @@ public static class VacancyReportWorkbook private const int ColClosingDate = 8; private const int ColLeadTime = 9; private const int ColStartDate = 10; - private const int ColInitiator = 11; - private const int ColReplaced = 12; - private const int ColClosedFlag = 14; - private const int ColRecruiter = 15; + 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; @@ -108,7 +118,7 @@ public static class VacancyReportWorkbook var groupNumber = 0; foreach (var item in group.Rows) - WriteRow(sheet, styles, rowIdx++, ++totalNumber, ++groupNumber, item); + rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item); } var lastDataRowIdx = rowIdx - 1; @@ -151,46 +161,87 @@ public static class VacancyReportWorkbook row.GetCell(ColGroupNumber).SetCellValue(name); } - private static void WriteRow( - XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item) + /// + /// Одна вакансия — столько строк, сколько у неё Lines (минимум одна). + /// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик. + /// + /// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по + /// вертикали: у объединённой области Excel хранит значение в верхней левой + /// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя — + /// SUM в итогах посчитал бы одно и то же несколько раз). + /// + private static int WriteVacancy( + XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item) { - var row = sheet.CreateRow(rowIdx); - var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки. + var lines = item.Lines; + var lineCount = Math.Max(lines.Count, 1); + var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки. - for (var col = 0; col < Columns.Length; col++) - row.CreateCell(col).CellStyle = styles.Data; + // Все строки вакансии — со стилем данных, иначе у продолжений не будет + // границ и объединённая область выглядит рваной. + for (var offset = 0; offset < lineCount; offset++) + { + var row = sheet.CreateRow(firstRowIdx + offset); + for (var col = 0; col < Columns.Length; col++) + row.CreateCell(col).CellStyle = styles.Data; + } - row.GetCell(ColTotalNumber).SetCellValue(totalNumber); - row.GetCell(ColGroupNumber).SetCellValue(groupNumber); - row.GetCell(ColPosition).SetCellValue(item.Position); - row.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty); - row.GetCell(ColVacancies).SetCellValue(item.VacanciesCount); - row.GetCell(ColClosed).SetCellValue(item.ClosedCount); - row.GetCell(ColInProgress).SetCellFormula($"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}"); + var first = sheet.GetRow(firstRowIdx); - SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate); - SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate); + 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 = row.GetCell(ColLeadTime); + var leadTimeCell = first.GetCell(ColLeadTime); leadTimeCell.CellStyle = styles.DataLeadTime; leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}"); } - SetDate(row.GetCell(ColStartDate), styles, item.StartDate); - - row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty); - row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty); - // Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в - // ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым - // в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали + first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty); + // Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном + // отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в + // заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали // в выгрузке. - row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag); - row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty); + 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) diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs index 9b5d700..ee7e3d9 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs @@ -107,14 +107,15 @@ public sealed class VacancyReportServiceTests : IClassFixture + string? replaced = null, + string? candidate = "Шакенов Анель") => new { Id = selectionId * 10, RequestStatus = status, SelectionRequestId = (double)selectionId, SelectionRequestListId = selectionListUrl, - CandidateName = "Шакенов Анель", + CandidateName = candidate, ReplacedEmployee = replaced, HireDate = hireDate }; @@ -195,9 +196,11 @@ public sealed class VacancyReportServiceTests : IClassFixture l.StartDate).ShouldBe([new DateOnly(2026, 8, 5), new DateOnly(2026, 8, 25)]); + row.Lines.Select(l => l.CandidateName).ShouldBe(["Дулин Евгений", "Бутиев Азамат"]); + } + + [Fact(DisplayName = "Отклонённый приём не даёт строки — кандидата в отчёте быть не должно")] + public async Task RejectedHire_ProducesNoLine() + { + const string name = "rejectedLine"; + var list = SelectionListUrl(name); + var (service, _) = await BuildAsync( + name, + [Selection(1, "Разнорабочий", vacancies: 2)], + [Execution(1)], + [ + Hire(1, list, candidate: "Принятый Кандидат"), + Hire(1, list, status: "Отклонено", candidate: "Отклонённый Кандидат") + ]); + + var result = await service.GetAsync(From, To, CancellationToken.None); + + var row = SingleRow(result.Value); + row.ClosedCount.ShouldBe(1); + row.Lines.ShouldHaveSingleItem().CandidateName.ShouldBe("Принятый Кандидат"); } [Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")] @@ -344,9 +373,28 @@ public sealed class VacancyReportServiceTests : IClassFixture r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик"); - rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull(); + rows.Single(r => r.Position == "Начальник геодезии") + .Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Серик"); + rows.Single(r => r.Position == "Инженер ВиВ") + .Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBeNull(); + } + + [Fact(DisplayName = "«Взамен кого» из заявки на приём важнее причины открытия вакансии")] + public async Task ReplacedEmployee_HireWins() + { + const string name = "replacedHire"; + var (service, _) = await BuildAsync( + name, + [Selection(1, "Юрист", openReason: "Замена уволенного сотрудника", openReasonDetails: "Из заявки")], + [Execution(1)], + [Hire(1, SelectionListUrl(name), replaced: "Из приёма")]); + + var result = await service.GetAsync(From, To, CancellationToken.None); + + SingleRow(result.Value).Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Из приёма"); } [Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")] diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs index 6861295..2bdd4b6 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs @@ -21,7 +21,8 @@ public sealed class VacancyReportWorkbookTests DateOnly? submitDate = null, DateOnly? closingDate = null, DateOnly? startDate = null, - string? recruiter = "Сергеева Надежда Ивановна") => + string? recruiter = "Сергеева Надежда Ивановна", + IReadOnlyList? lines = null) => new( position, "офис", @@ -29,11 +30,16 @@ public sealed class VacancyReportWorkbookTests closed, submitDate ?? new DateOnly(2026, 7, 8), closingDate, - startDate, "Ким Н.Ю.", - null, closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes, - recruiter); + recruiter, + // По умолчанию одна строка — вакансия без приёмов либо с одним; + // тесты многострочности передают lines явно. + lines ?? [new VacancyReportLine(null, startDate, null)]); + + /// Строка листа в пределах вакансии — то, что различается между приёмами. + private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) => + new(candidate, startDate, replaced); private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups) { @@ -72,7 +78,7 @@ public sealed class VacancyReportWorkbookTests return cell?.CellType == CellType.Formula ? cell.CellFormula : ""; } - [Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")] + [Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")] public void Header_MatchesSample() { var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); @@ -84,10 +90,11 @@ public sealed class VacancyReportWorkbookTests StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки"); StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии"); StringAt(sheet, 1, 11).ShouldBe("Дата выхода"); - StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки"); - StringAt(sheet, 1, 14).ShouldBe("Комментарии"); - StringAt(sheet, 1, 16).ShouldBe("Рекрутер"); - IsEmptyAt(sheet, 1, 17).ShouldBeTrue(); + StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника"); + StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки"); + StringAt(sheet, 1, 15).ShouldBe("Комментарии"); + StringAt(sheet, 1, 17).ShouldBe("Рекрутер"); + IsEmptyAt(sheet, 1, 18).ShouldBeTrue(); } [Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")] @@ -124,11 +131,76 @@ public sealed class VacancyReportWorkbookTests DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30)); DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8)); DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17)); - StringAt(sheet, 2, 12).ShouldBe("Ким Н.Ю."); + StringAt(sheet, 2, 13).ShouldBe("Ким Н.Ю."); // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. - IsEmptyAt(sheet, 2, 14).ShouldBeTrue(); - StringAt(sheet, 2, 15).ShouldBe("да"); - StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна"); + IsEmptyAt(sheet, 2, 15).ShouldBeTrue(); + StringAt(sheet, 2, 16).ShouldBe("да"); + StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна"); + } + + [Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")] + public void SeveralHires_OneRowEachWithMergedCommonCells() + { + var sheet = BuildListSheet(new VacancyReportGroup("", [ + Row("Разнорабочий", vacancies: 4, closed: 4, + submitDate: new DateOnly(2026, 7, 10), + lines: [ + Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)), + Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)), + Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.") + ]) + ])); + + // Построчные колонки — своё значение в каждой строке. + StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович"); + StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич"); + StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович"); + DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19)); + DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21)); + DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25)); + StringAt(sheet, 4, 14).ShouldBe("Оспанов К."); + + // Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel + // хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах. + StringAt(sheet, 2, 3).ShouldBe("Разнорабочий"); + IsEmptyAt(sheet, 3, 3).ShouldBeTrue(); + IsEmptyAt(sheet, 4, 3).ShouldBeTrue(); + IntAt(sheet, 2, 5).ShouldBe(4); + IsEmptyAt(sheet, 3, 5).ShouldBeTrue(); + + // И сами объединения заведены — иначе продолжения выглядели бы пустыми. + var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList(); + merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2); + // Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы. + merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName + merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate + } + + [Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")] + public void SingleLine_NoMergedRegions() + { + var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); + + sheet.NumMergedRegions.ShouldBe(0); + } + + [Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")] + public void Totals_CountVacancyOnce_NotPerHire() + { + var sheet = BuildListSheet(new VacancyReportGroup("", [ + Row("Разнорабочий", vacancies: 4, closed: 4, lines: [ + Line("Первый", new DateOnly(2026, 8, 19)), + Line("Второй", new DateOnly(2026, 8, 21)) + ]), + Row("Логист", vacancies: 1, closed: 0) + ])); + + // Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й. + FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)"); + // Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение), + // поэтому SUM даёт 4+1=5, а не 4+4+1. + IsEmptyAt(sheet, 3, 5).ShouldBeTrue(); + IntAt(sheet, 4, 5).ShouldBe(1); } [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")] -- 2.34.1