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"); + } +}