Отчет по вакансиям #20
@@ -34,8 +34,15 @@
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="NPOI" Version="2.7.6" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||
<!--
|
||||
Только чтобы поднять транзитивную версию из NPOI (8.0.2 с известной
|
||||
уязвимостью, см. GHSA-8q5v-6pqq-x66h и соседние advisories той же пачки)
|
||||
— напрямую не используется. Убрать, когда NPOI сам подтянет исправленную.
|
||||
-->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.11" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузки отчётов файлами. В отличие от остальных контроллеров отдаёт не
|
||||
/// ApiResponse, а сам файл: получатель — кнопка «скачать» в портале и Power
|
||||
/// Automate, обоим нужен готовый xlsx, а не JSON с телом в base64.
|
||||
/// </summary>
|
||||
[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";
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям за период — лист «Список вакансий (все)» по данным
|
||||
/// заявок на подбор и на приём.
|
||||
///
|
||||
/// Период отбирает заявки по «Дате подачи заявки» (колонка H отчёта). Заявки
|
||||
/// на приём периодом НЕ ограничены: у вакансии, поданной в начале интервала,
|
||||
/// приём мог оформляться уже после его конца.
|
||||
/// </summary>
|
||||
[HttpGet("vacancies")]
|
||||
[Produces(XlsxContentType)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
|
||||
|
||||
builder.Services.AddHangfire(configuration => configuration
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
|
||||
@@ -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<ListPartitionService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
services.AddScoped<CreateNextListPartitionJob>();
|
||||
services.AddScoped<VacancyReportService>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Строка ФАЗЫ ИСПОЛНЕНИЯ из списка «Заявка на подбор — согласование». Отчёту она
|
||||
/// даёт две колонки, которых нет в самой заявке: рекрутёр (исполнитель шага) и
|
||||
/// дата закрытия вакансии.
|
||||
///
|
||||
/// Дата закрытия — это колонка Date выполненной строки: request-portal
|
||||
/// перезаписывает её в момент решения по шагу (см. setDecision в
|
||||
/// shared/approval/approvalList.service.ts), так что у строки со статусом
|
||||
/// «Выполнено» там именно дата закрытия, а не дата получения шага в работу.
|
||||
/// </summary>
|
||||
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 = "Исполнение";
|
||||
|
||||
/// <summary>Статус выполненной строки исполнения — по нему считаем вакансию закрытой.</summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «Прием на работу — данные» в том объёме, в каком она нужна отчёту:
|
||||
/// на какую заявку на подбор ссылается, кого и когда приняли.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>Заявка на приём без ссылки на подбор отчёту бесполезна — её отбрасываем.</summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>Настройки отчётов (секция Reports в конфигурации).</summary>
|
||||
public sealed class ReportsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// PageUrl типа заявки «Заявка на подбор» (см. request-portal,
|
||||
/// forms/RecruitingSelection/model.ts, FORM_ID) — по нему ищется RequestType,
|
||||
/// из партиций которого берутся сами вакансии.
|
||||
/// </summary>
|
||||
public string RecruitingSelectionPageUrl { get; set; } = "recruiting-selection";
|
||||
|
||||
/// <summary>
|
||||
/// PageUrl типа заявки «Приём на работу» — из его партиций берутся закрытия
|
||||
/// вакансий: заявка на приём ссылается на заявку на подбор колонками
|
||||
/// SelectionRequestId / SelectionRequestListId.
|
||||
/// </summary>
|
||||
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «Заявка на подбор — данные». Имена колонок — как в request-portal
|
||||
/// (provisioning.json формы RecruitingSelection).
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
/// <summary>
|
||||
/// Поля, которые нужны отчёту, — перечислены явно: список данных широкий
|
||||
/// (полсотни колонок, часть — Note с многострочным текстом), и тянуть его
|
||||
/// целиком по всем партициям только ради шестнадцати колонок незачем.
|
||||
/// </summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Чтение полей из ответа SharePoint REST. Те же грабли, что разобраны в
|
||||
/// Reminders/ApprovalRow: Number-колонка приходит дробной («1.0»), и обычный
|
||||
/// TryGetInt32 на ней возвращает false, а часть полей SharePoint отдаёт строками.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Дата БЕЗ приведения к другой зоне: колонки отчёта — dateOnly, SharePoint
|
||||
/// отдаёт их полуночью, и любой сдвиг зоны увёл бы день на соседний.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Title развёрнутого персонального поля (…/Title через $expand). null — поле пустое.</summary>
|
||||
public static string? PersonTitle(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var person) && person.ValueKind == JsonValueKind.Object
|
||||
? String(person, "Title")
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям за период — ровно то, что ложится на лист «Список вакансий
|
||||
/// (все)» в xlsx: интервал плюс вакансии, разбитые по объектам/проектам.
|
||||
/// </summary>
|
||||
public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList<VacancyReportGroup> Groups);
|
||||
|
||||
/// <summary>
|
||||
/// Объект/проект и его вакансии. Имя группы становится строкой-разделителем на
|
||||
/// листе (как «КЭС КУРЧАТОВ» в образце отчёта); пустое имя — вакансии, у которых
|
||||
/// объект в заявке не указан, они идут первым блоком без разделителя.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportGroup(string Name, IReadOnlyList<VacancyReportRow> Rows);
|
||||
|
||||
/// <summary>
|
||||
/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк
|
||||
/// на листе, там они объединяются по вертикали; то, что различается от приёма к
|
||||
/// приёму, лежит в Lines.
|
||||
///
|
||||
/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся:
|
||||
/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в
|
||||
/// файле поправят руками.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportRow(
|
||||
// «Вакансия» — должность из заявки на подбор.
|
||||
string Position,
|
||||
// «Подразделение» ответственного за подачу заявки.
|
||||
string? Department,
|
||||
// «Кол-во вакансий».
|
||||
int VacanciesCount,
|
||||
// «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
|
||||
int ClosedCount,
|
||||
// «Дата подачи заявки».
|
||||
DateOnly? SubmitDate,
|
||||
// «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
|
||||
DateOnly? ClosingDate,
|
||||
// «Инициатор заявки».
|
||||
string? Initiator,
|
||||
// «Закрыта вакансия»: да / нет / частично.
|
||||
string ClosedFlag,
|
||||
// «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
|
||||
string? Recruiter,
|
||||
// Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine).
|
||||
IReadOnlyList<VacancyReportLine> Lines);
|
||||
|
||||
/// <summary>
|
||||
/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками.
|
||||
/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии»
|
||||
/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и
|
||||
/// свой «взамен кого».
|
||||
///
|
||||
/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все
|
||||
/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с
|
||||
/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число
|
||||
/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportLine(
|
||||
// «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём.
|
||||
string? CandidateName,
|
||||
// «Дата выхода» — дата приёма этого кандидата.
|
||||
DateOnly? StartDate,
|
||||
// «Вместо сотрудника Ф.И.О.».
|
||||
string? ReplacedEmployee);
|
||||
|
||||
/// <summary>Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.</summary>
|
||||
public static class VacancyClosedFlags
|
||||
{
|
||||
public const string Yes = "да";
|
||||
public const string No = "нет";
|
||||
public const string Partial = "частично";
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using System.Globalization;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор»
|
||||
/// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на
|
||||
/// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки
|
||||
/// SelectionRequestId / SelectionRequestListId заявки на приём.
|
||||
///
|
||||
/// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного
|
||||
/// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow).
|
||||
///
|
||||
/// ПОЧЕМУ ЗДЕСЬ items REST ПО ПАРТИЦИЯМ, А НЕ SEARCH (разбирались 2026-08-28).
|
||||
/// Search выглядит уместнее — один KQL сразу по всем партициям, и отставание
|
||||
/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
|
||||
/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
|
||||
/// через REST):
|
||||
/// • «Заявка на подбор»: OpenReason, OpenReasonDetails;
|
||||
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
|
||||
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
|
||||
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
|
||||
/// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не
|
||||
/// помогает — пробовали.
|
||||
///
|
||||
/// Причина, если понадобится чинить: одноимённые колонки в РАЗНЫХ списках — это
|
||||
/// разные поля со своими GUID, а значит и со своими crawled property
|
||||
/// (ows_Position, ows_Position1, …); автосозданное managed property привязано
|
||||
/// только к одному из них. Поэтому, например, PositionOWSTEXT отдаёт значения в
|
||||
/// «Заявка на подбор — данные» и молчит в «Прием на работу — данные». Проверять
|
||||
/// надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим
|
||||
/// занимается `sp-write props` (репозиторий C:\work\git\tools), он же говорит,
|
||||
/// нужно ли домапить crawled property или свойства нет вовсе.
|
||||
///
|
||||
/// Значит эти поля пришлось бы дочитывать построчно батчами по 100 — и на 1000
|
||||
/// заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST.
|
||||
/// В list view threshold (5000) REST при этом не упирается by design: партиции
|
||||
/// режутся на 4900 элементов (ListPartitioning:ItemCountThreshold), то есть ни
|
||||
/// один $filter не сканирует больше предела.
|
||||
///
|
||||
/// Вернуться к Search имеет смысл, только если в Search Schema заведут
|
||||
/// работающие managed properties для перечисленных выше полей.
|
||||
/// </summary>
|
||||
public sealed class VacancyReportService(
|
||||
ApprovalRouteDbContext db,
|
||||
ISharePointClient sharePoint,
|
||||
ListPartitionRegistry partitionRegistry,
|
||||
IOptions<ReportsOptions> options,
|
||||
ILogger<VacancyReportService> logger)
|
||||
{
|
||||
public async Task<Result<VacancyReport>> 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<HireLinkRow>)[]
|
||||
: await ReadHiresAsync(hireType, cancellationToken);
|
||||
|
||||
var hiresBySelection = hires
|
||||
.GroupBy(x => (x.SelectionRequestId, ListId: NormalizeListId(x.SelectionRequestListId)))
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<HireLinkRow>)g.ToList());
|
||||
|
||||
return Result.Ok(new VacancyReport(from, to, Group(selections, hiresBySelection)));
|
||||
}
|
||||
|
||||
private async Task<RequestType?> FindTypeAsync(string pageUrl, CancellationToken cancellationToken) =>
|
||||
await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.PageUrl == pageUrl && x.ListId != null && x.SiteUrl != null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Заявки на подбор за период по ВСЕМ партициям типа — вакансия, уехавшая в
|
||||
/// архивный список, из отчёта пропадать не должна: подбор по ней мог начаться
|
||||
/// задолго до того, как для новых заявок завели следующую партицию.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionsAsync(
|
||||
RequestType type, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<SelectionWithExecution>();
|
||||
|
||||
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<IReadOnlyList<SelectionWithExecution>> 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<SelectionRow>()
|
||||
.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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строки фазы «Исполнение» партиции, свёрнутые по заявке. Заголовок списка
|
||||
/// согласования выводится из ЖИВОГО заголовка списка данных, а не из url:
|
||||
/// список могли переименовать руками.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<int, ExecutionInfo>> 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<ExecutionRow>()
|
||||
.GroupBy(x => x.TicketId)
|
||||
.ToDictionary(g => g.Key, ExecutionInfo.From);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<HireLinkRow>> ReadHiresAsync(
|
||||
RequestType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<HireLinkRow>();
|
||||
|
||||
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<HireLinkRow>());
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(
|
||||
e, "Отчёт по вакансиям: приёмы из партиции №{Number} ({ListId}) не прочитаны", number, listId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Все партиции типа — архив плюс текущая (она в реестр не пишется, а синтезируется).</summary>
|
||||
private async Task<IReadOnlyList<(string ListId, int Number)>> 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<HireLinkRow>> hiresBySelection)
|
||||
{
|
||||
var (row, execution) = selection;
|
||||
|
||||
var hires = hiresBySelection.TryGetValue((row.Id, NormalizeListId(row.ListId)), out var found)
|
||||
? found
|
||||
: [];
|
||||
|
||||
// Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
|
||||
// кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
|
||||
// ложью («вот кого приняли»).
|
||||
var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
|
||||
|
||||
return new VacancyReportRow(
|
||||
row.Position ?? string.Empty,
|
||||
row.DepartmentName,
|
||||
row.VacanciesCount,
|
||||
accepted.Count,
|
||||
row.SubmitDate,
|
||||
execution.ClosingDate,
|
||||
ShortName(row.Initiator),
|
||||
ClosedFlag(row.VacanciesCount, accepted.Count),
|
||||
execution.Recruiter,
|
||||
Lines(row, accepted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
|
||||
/// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
|
||||
/// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
|
||||
/// заполняется из причины открытия вакансии.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<VacancyReportLine> Lines(SelectionRow row, IReadOnlyList<HireLinkRow> accepted)
|
||||
{
|
||||
if (accepted.Count == 0)
|
||||
return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
|
||||
|
||||
return accepted
|
||||
// Порядок строк внутри вакансии — по дате выхода: так в отчёте видно,
|
||||
// кто вышел раньше. Приём без даты уходит в конец.
|
||||
.OrderBy(x => x.HireDate ?? DateOnly.MaxValue)
|
||||
.ThenBy(x => x.CandidateName, StringComparer.CurrentCulture)
|
||||
.Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле
|
||||
/// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если
|
||||
/// причина — замена уволенного: при «новой штатной единице» в том же поле
|
||||
/// лежит совсем другой текст.
|
||||
///
|
||||
/// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии
|
||||
/// он одинаковый — это нормально: причина открытия у вакансии одна.
|
||||
///
|
||||
/// hire = null — у вакансии приёмов нет вовсе, остаётся только откат.
|
||||
/// </summary>
|
||||
private static string? ReplacedEmployee(SelectionRow row, HireLinkRow? hire)
|
||||
{
|
||||
if (hire is not null && !string.IsNullOrWhiteSpace(hire.ReplacedEmployee))
|
||||
return hire.ReplacedEmployee;
|
||||
|
||||
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
|
||||
}
|
||||
|
||||
private static string ClosedFlag(int vacancies, int closed) => closed switch
|
||||
{
|
||||
0 => VacancyClosedFlags.No,
|
||||
_ when closed >= vacancies => VacancyClosedFlags.Yes,
|
||||
_ => VacancyClosedFlags.Partial,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// «Фамилия Имя Отчество» → «Фамилия И.О.» — так инициатор записан в образце
|
||||
/// отчёта («Ким Н.Ю.»). Имя из одного слова остаётся как есть.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Группировка по объекту/проекту — она же порядок строк на листе: сначала
|
||||
/// вакансии без объекта (в образце это верхний блок без разделителя), затем
|
||||
/// объекты по алфавиту, внутри группы — по дате подачи заявки.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<VacancyReportGroup> Group(
|
||||
IEnumerable<SelectionWithExecution> selections,
|
||||
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> 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();
|
||||
|
||||
/// <summary>
|
||||
/// Фильтр периода по «Дате подачи заявки» с откатом на дату создания элемента:
|
||||
/// SubmitDate — поле формы, заполняется руками и часто пустое, и без отката
|
||||
/// такие заявки не попали бы в отчёт вообще.
|
||||
///
|
||||
/// Верхняя граница — по НАЧАЛУ следующего дня и строгим «lt»: даты в SharePoint
|
||||
/// лежат с временем, и «le {to}» отсекло бы всё, что записано в сам день `to`
|
||||
/// позже полуночи.
|
||||
/// </summary>
|
||||
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')";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
|
||||
/// конвенции «<Форма> <N> — данные|согласование»: ListTitleConvention
|
||||
/// в Infrastructure — internal, а здесь нужен только этот один переход.
|
||||
/// </summary>
|
||||
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), " — согласование");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Приведение server-relative url партиции к сравнимому виду: портал пишет
|
||||
/// SelectionRequestListId не всегда в том же регистре и с тем же хвостовым слешем.
|
||||
/// </summary>
|
||||
private static string NormalizeListId(string? listId) =>
|
||||
(listId ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Заявка на подбор вместе с тем, что о ней знает её фаза исполнения.</summary>
|
||||
internal sealed record SelectionWithExecution(SelectionRow Row, ExecutionInfo Execution);
|
||||
|
||||
/// <summary>Статусы списка «Прием на работу — данные», которые важны отчёту.</summary>
|
||||
internal static class HireRequestStatus
|
||||
{
|
||||
public const string Rejected = "Отклонено";
|
||||
}
|
||||
|
||||
/// <summary>Значение колонки «Причина открытия вакансии», при котором заполнено «вместо кого».</summary>
|
||||
internal static class SelectionOpenReason
|
||||
{
|
||||
public const string Replacement = "Замена уволенного сотрудника";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Что фаза исполнения заявки на подбор даёт отчёту. Рекрутёр — исполнитель
|
||||
/// ПОСЛЕДНЕГО шага исполнения (шагов может быть несколько, вакансию довёл
|
||||
/// последний); дата закрытия появляется только когда ВСЕ шаги исполнения
|
||||
/// выполнены — пока хоть один в работе, вакансия не закрыта.
|
||||
/// </summary>
|
||||
internal sealed record ExecutionInfo(string? Recruiter, DateOnly? ClosingDate)
|
||||
{
|
||||
public static readonly ExecutionInfo Empty = new(null, null);
|
||||
|
||||
public static ExecutionInfo From(IEnumerable<ExecutionRow> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в
|
||||
/// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в
|
||||
/// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше
|
||||
/// рисуется как две круговые диаграммы.
|
||||
///
|
||||
/// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а
|
||||
/// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр.
|
||||
/// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же,
|
||||
/// как единица подсчёта работает на первом листе.
|
||||
/// </summary>
|
||||
public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList<RecruiterBreakdown> Recruiters)
|
||||
{
|
||||
/// <summary>Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя).</summary>
|
||||
public const string UnassignedLabel = "Без рекрутёра";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода
|
||||
/// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с
|
||||
/// Total/InProgress при округлении.
|
||||
/// </summary>
|
||||
public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed)
|
||||
{
|
||||
/// <summary>Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы).</summary>
|
||||
public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0;
|
||||
|
||||
/// <summary>Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы).</summary>
|
||||
public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0;
|
||||
|
||||
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице.</summary>
|
||||
public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0;
|
||||
|
||||
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице.</summary>
|
||||
public double ClosedRate => Total > 0 ? (double)Closed / Total : 0;
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
using NPOI.OOXML.XSSF.UserModel;
|
||||
using NPOI.SS.UserModel;
|
||||
using NPOI.SS.UserModel.Charts;
|
||||
using NPOI.SS.Util;
|
||||
using NPOI.XSSF.UserModel;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
|
||||
/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
|
||||
/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
|
||||
/// круговыми диаграммами).
|
||||
///
|
||||
/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
|
||||
/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
|
||||
/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
|
||||
///
|
||||
/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
|
||||
/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
|
||||
/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
|
||||
///
|
||||
/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
|
||||
/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
|
||||
/// руками, и после правки строки цифры должны пересчитаться сами.
|
||||
/// </summary>
|
||||
public static class VacancyReportWorkbook
|
||||
{
|
||||
private const string SheetName = "Список вакансий (все)";
|
||||
private const string SummarySheetName = "Результаты";
|
||||
|
||||
private const string DateFormat = "dd.mm.yyyy";
|
||||
private const string WholePercentFormat = "0%";
|
||||
private const string PrecisePercentFormat = "0.00%";
|
||||
|
||||
/// <summary>Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).</summary>
|
||||
private static readonly (string Title, double Width)[] Columns =
|
||||
[
|
||||
(" ", 6.1),
|
||||
("№", 4.9),
|
||||
("Вакансия", 57.4),
|
||||
("Подразделение", 17.1),
|
||||
("Кол-во вакансий", 8.3),
|
||||
("количество закрытых вакансий", 9.1),
|
||||
("количество вакансий в работе", 11.6),
|
||||
("Дата подачи заявки", 14.7),
|
||||
("Дата закрытия вакансии", 14.9),
|
||||
("Срок отработки", 12.3),
|
||||
("Дата выхода", 13.9),
|
||||
("Ф.И.О. нанимаемого сотрудника", 30.0),
|
||||
("Инициатор заявки", 26.9),
|
||||
("Вместо сотрудника Ф.И.О.", 26.0),
|
||||
("Комментарии", 51.3),
|
||||
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
|
||||
("Рекрутер", 23.9),
|
||||
];
|
||||
|
||||
// Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
|
||||
private const int ColTotalNumber = 0;
|
||||
private const int ColGroupNumber = 1;
|
||||
private const int ColPosition = 2;
|
||||
private const int ColDepartment = 3;
|
||||
private const int ColVacancies = 4;
|
||||
private const int ColClosed = 5;
|
||||
private const int ColInProgress = 6;
|
||||
private const int ColSubmitDate = 7;
|
||||
private const int ColClosingDate = 8;
|
||||
private const int ColLeadTime = 9;
|
||||
private const int ColStartDate = 10;
|
||||
private const int ColCandidateName = 11;
|
||||
private const int ColInitiator = 12;
|
||||
private const int ColReplaced = 13;
|
||||
private const int ColComment = 14;
|
||||
private const int ColClosedFlag = 15;
|
||||
private const int ColRecruiter = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
|
||||
/// общие — при нескольких приёмах по одной заявке на подбор они объединяются
|
||||
/// по вертикали (см. WriteVacancy).
|
||||
/// </summary>
|
||||
private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
|
||||
|
||||
private const int HeaderRowIdx = 0;
|
||||
|
||||
public static byte[] Build(VacancyReport report)
|
||||
{
|
||||
using var workbook = new XSSFWorkbook();
|
||||
var styles = new Styles(workbook);
|
||||
|
||||
BuildVacancyListSheet(workbook, styles, report);
|
||||
BuildSummarySheet(workbook, styles, report);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.Write(stream, leaveOpen: true);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
|
||||
public static string FileName(VacancyReport report) =>
|
||||
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
|
||||
|
||||
// ==================== Лист 1: «Список вакансий (все)» ====================
|
||||
|
||||
private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||
{
|
||||
var sheet = (XSSFSheet)workbook.CreateSheet(SheetName);
|
||||
|
||||
WriteHeader(sheet, styles);
|
||||
|
||||
var rowIdx = HeaderRowIdx + 1;
|
||||
var totalNumber = 0;
|
||||
var firstDataRowIdx = rowIdx;
|
||||
|
||||
foreach (var group in report.Groups)
|
||||
{
|
||||
if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name);
|
||||
|
||||
var groupNumber = 0;
|
||||
foreach (var item in group.Rows)
|
||||
rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item);
|
||||
}
|
||||
|
||||
var lastDataRowIdx = rowIdx - 1;
|
||||
|
||||
// Итоги — только если есть что суммировать: SUM по пустому диапазону
|
||||
// (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном.
|
||||
if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx);
|
||||
|
||||
FinishVacancyListSheet(sheet, styles, lastDataRowIdx);
|
||||
}
|
||||
|
||||
private static void WriteHeader(XSSFSheet sheet, Styles styles)
|
||||
{
|
||||
var row = sheet.CreateRow(HeaderRowIdx);
|
||||
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
|
||||
// одну строку из трёх (высота 90 — из образца отчёта).
|
||||
row.HeightInPoints = 90f;
|
||||
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
{
|
||||
var (title, width) = Columns[col];
|
||||
var cell = row.CreateCell(col);
|
||||
cell.SetCellValue(title);
|
||||
cell.CellStyle = styles.Header;
|
||||
sheet.SetColumnWidth(col, (int)Math.Round(width * 256));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
|
||||
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
|
||||
/// начинается заново — так же, как в ручном отчёте.
|
||||
/// </summary>
|
||||
private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
|
||||
{
|
||||
var row = sheet.CreateRow(rowIdx);
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.GroupSeparator;
|
||||
|
||||
row.GetCell(ColGroupNumber).SetCellValue(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
|
||||
/// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
|
||||
///
|
||||
/// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
|
||||
/// вертикали: у объединённой области Excel хранит значение в верхней левой
|
||||
/// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
|
||||
/// SUM в итогах посчитал бы одно и то же несколько раз).
|
||||
/// </summary>
|
||||
private static int WriteVacancy(
|
||||
XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
|
||||
{
|
||||
var lines = item.Lines;
|
||||
var lineCount = Math.Max(lines.Count, 1);
|
||||
var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки.
|
||||
|
||||
// Все строки вакансии — со стилем данных, иначе у продолжений не будет
|
||||
// границ и объединённая область выглядит рваной.
|
||||
for (var offset = 0; offset < lineCount; offset++)
|
||||
{
|
||||
var row = sheet.CreateRow(firstRowIdx + offset);
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.Data;
|
||||
}
|
||||
|
||||
var first = sheet.GetRow(firstRowIdx);
|
||||
|
||||
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
|
||||
first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
|
||||
first.GetCell(ColPosition).SetCellValue(item.Position);
|
||||
first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
|
||||
first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
|
||||
first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
|
||||
first.GetCell(ColInProgress).SetCellFormula(
|
||||
$"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
|
||||
|
||||
SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
|
||||
SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
|
||||
|
||||
// «Срок отработки» — разница дат в днях, и только когда известны обе:
|
||||
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
|
||||
// дней (пустая ячейка — это 00.01.1900).
|
||||
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
||||
{
|
||||
var leadTimeCell = first.GetCell(ColLeadTime);
|
||||
leadTimeCell.CellStyle = styles.DataLeadTime;
|
||||
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
||||
}
|
||||
|
||||
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
||||
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
|
||||
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
|
||||
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
||||
// в выгрузке.
|
||||
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
||||
first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
|
||||
|
||||
// Построчные поля — каждое в свою строку.
|
||||
for (var offset = 0; offset < lines.Count; offset++)
|
||||
{
|
||||
var row = sheet.GetRow(firstRowIdx + offset);
|
||||
var line = lines[offset];
|
||||
|
||||
row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
|
||||
SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
|
||||
row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
|
||||
}
|
||||
|
||||
// Объединяем общие колонки только когда строк действительно больше одной:
|
||||
// область 1×1 Excel считает некорректной и портит файл.
|
||||
if (lineCount > 1)
|
||||
{
|
||||
var lastRowIdx = firstRowIdx + lineCount - 1;
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
{
|
||||
if (PerLineColumns.Contains(col)) continue;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
|
||||
}
|
||||
}
|
||||
|
||||
return lineCount;
|
||||
}
|
||||
|
||||
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
|
||||
{
|
||||
var row = sheet.CreateRow(rowIdx);
|
||||
var firstExcelRow = firstDataRowIdx + 1;
|
||||
var lastExcelRow = lastDataRowIdx + 1;
|
||||
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.Totals;
|
||||
|
||||
foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
|
||||
{
|
||||
var letter = ColLetter(col);
|
||||
row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
|
||||
/// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
|
||||
/// вместе с отфильтрованными строками.
|
||||
/// </summary>
|
||||
private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
|
||||
{
|
||||
// ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в
|
||||
// отличие от чистого OOXML, где стиль колонки должен уступать явному
|
||||
// стилю ячейки) переписывает им стиль уже существующих ячеек колонки
|
||||
// целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия
|
||||
// вакансии»/«Дата выхода» лишались синей заливки и получали формат даты
|
||||
// вместо неё. Формат даты у самих дат и так стоит явно, ячейка за
|
||||
// ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных,
|
||||
// формат не унаследуют, но это не тот случай, ради которого стоит опять
|
||||
// ломать шапку.
|
||||
//
|
||||
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
|
||||
// какая колонка какая.
|
||||
sheet.CreateFreezePane(0, HeaderRowIdx + 1);
|
||||
|
||||
if (lastDataRowIdx > HeaderRowIdx)
|
||||
sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
|
||||
}
|
||||
|
||||
private static void SetDate(ICell cell, Styles styles, DateOnly? date)
|
||||
{
|
||||
if (date is null) return;
|
||||
|
||||
cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
|
||||
cell.CellStyle = styles.DataDate;
|
||||
}
|
||||
|
||||
// ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ====================
|
||||
|
||||
/// <summary>
|
||||
/// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт,
|
||||
/// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого
|
||||
/// рекрутёра в общем объёме — они же источник данных для двух круговых
|
||||
/// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в
|
||||
/// ручном отчёте HR.
|
||||
/// </summary>
|
||||
private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||
{
|
||||
var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName);
|
||||
sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256));
|
||||
|
||||
var titleRow = sheet.CreateRow(0);
|
||||
titleRow.CreateCell(0).SetCellValue("результаты");
|
||||
titleRow.GetCell(0).CellStyle = styles.Title;
|
||||
|
||||
var summary = VacancyReportSummary.Build(report);
|
||||
if (summary.Recruiters.Count == 0)
|
||||
{
|
||||
sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период.");
|
||||
return;
|
||||
}
|
||||
|
||||
var recruiters = summary.Recruiters;
|
||||
for (var i = 0; i < recruiters.Count; i++)
|
||||
sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256));
|
||||
|
||||
WriteHorizontalTable(sheet, styles, summary);
|
||||
|
||||
const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто»
|
||||
var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ
|
||||
var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1;
|
||||
WriteVerticalTable(
|
||||
sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal,
|
||||
recruiters, r => r.ShareOfTotal(summary.GrandTotal));
|
||||
|
||||
var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1;
|
||||
var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1;
|
||||
WriteVerticalTable(
|
||||
sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress,
|
||||
recruiters, r => r.ShareOfInProgress(summary.GrandInProgress));
|
||||
|
||||
// Якоря диаграмм считаются НЕЗАВИСИМО от строк таблиц: у таблицы может
|
||||
// быть 2 строки, а у диаграммы фиксированная высота ChartHeightRows — с
|
||||
// якорем "на уровне шапки своей таблицы" вторая диаграмма при коротких
|
||||
// таблицах вставала бы внутрь вертикального диапазона первой (см.
|
||||
// ChartHeightRows). Обе выравнены по левому краю первой (горизонтальной)
|
||||
// таблицы и просто идут подряд сверху вниз с отступом.
|
||||
var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
|
||||
var chart1TopRowIdx = HeaderRowIdx + 1;
|
||||
var chart2TopRowIdx = chart1TopRowIdx + ChartHeightRows + 1;
|
||||
|
||||
CreatePieChart(
|
||||
sheet, "Объём вакансий " + period,
|
||||
firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx);
|
||||
CreatePieChart(
|
||||
sheet, "Объём вакансий в работе " + period,
|
||||
firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры
|
||||
/// колонками, как в образце отчёта (там же W:Y).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого —
|
||||
/// красным полужирным, как в образце) плюс по строке на рекрутёра с его
|
||||
/// долей в этом итого. Возвращаемый диапазон строк — вход для круговой
|
||||
/// диаграммы (см. CreatePieChart).
|
||||
/// </summary>
|
||||
private static void WriteVerticalTable(
|
||||
XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total,
|
||||
IReadOnlyList<RecruiterBreakdown> recruiters, Func<RecruiterBreakdown, double> share)
|
||||
{
|
||||
var headerRow = sheet.CreateRow(headerRowIdx);
|
||||
SetCell(headerRow, 0, label, styles.SummaryBlueHeader);
|
||||
SetCell(headerRow, 1, total, styles.SummaryBlueTotal);
|
||||
|
||||
// Заливка — на всю ширину блока (по числу рекрутёров, как в горизонтальной
|
||||
// таблице выше), а не только под текстом заголовка: иначе шапка выглядит
|
||||
// как две узкие цветные ячейки на фоне остального белого листа, а не как
|
||||
// сплошная плашка над таблицей.
|
||||
for (var col = 2; col <= recruiters.Count; col++)
|
||||
SetCell(headerRow, col, string.Empty, styles.SummaryBlueHeader);
|
||||
|
||||
for (var i = 0; i < recruiters.Count; i++)
|
||||
{
|
||||
var row = sheet.CreateRow(headerRowIdx + 1 + i);
|
||||
SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText);
|
||||
SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Высота якоря диаграммы в строках — фиксированная и не зависит от числа
|
||||
/// рекрутёров: подгонять размер круга под число категорий смысла нет, малое
|
||||
/// число секторов на большом круге читается ничуть не хуже. Используется и
|
||||
/// при размещении диаграмм (см. BuildSummarySheet — вторая идёт сразу под
|
||||
/// первой, без наложения).
|
||||
/// </summary>
|
||||
private const int ChartHeightRows = 14;
|
||||
|
||||
/// <summary>Круговая диаграмма по одной вертикальной таблице: категории — колонка A (имена рекрутёров), значения — колонка B (доля).</summary>
|
||||
private static void CreatePieChart(
|
||||
XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx)
|
||||
{
|
||||
if (rowCount == 0) return;
|
||||
|
||||
var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch();
|
||||
var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + ChartHeightRows);
|
||||
var chart = (XSSFChart)drawing.CreateChart(anchor);
|
||||
chart.SetTitle(title);
|
||||
|
||||
var lastDataRowIdx = firstDataRowIdx + rowCount - 1;
|
||||
var data = chart.ChartDataFactory.CreatePieChartData<string, double>();
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Все стили обоих листов — созданы один раз на книгу и переиспользуются:
|
||||
/// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а
|
||||
/// не собирается по кусочкам, поэтому под каждую итоговую комбинацию
|
||||
/// заливка+шрифт+формат нужен отдельный объект стиля.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter, диаграммы. -->
|
||||
<PackageReference Include="NPOI" />
|
||||
<PackageReference Include="FluentResults" />
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза
|
||||
/// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и
|
||||
/// когда приняли).
|
||||
///
|
||||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности
|
||||
/// дробные «1.0» у Number-колонок).
|
||||
/// </summary>
|
||||
public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||
private const string DataListTitle = "Заявка на подбор — данные";
|
||||
private const string ApprovalListTitle = "Заявка на подбор — согласование";
|
||||
|
||||
private static readonly DateOnly From = new(2026, 7, 1);
|
||||
private static readonly DateOnly To = new(2026, 8, 31);
|
||||
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly RequestTypeService _requestTypeService;
|
||||
|
||||
public VacancyReportServiceTests(PostgresDatabaseFixture fixture)
|
||||
{
|
||||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||
}
|
||||
|
||||
// База у тестов класса общая, а сервис ищет тип заявки по PageUrl — поэтому
|
||||
// у каждого теста свои списки и PageUrl (оба выводятся из имени теста), и
|
||||
// тесты не видят записи друг друга.
|
||||
private static string SelectionListUrl(string name) => $"/sites/requests/Lists/Подбор-{name}";
|
||||
private static string HireListUrl(string name) => $"/sites/requests/Lists/Приём-{name}";
|
||||
|
||||
private async Task CreateTypeAsync(string name, string listUrl, string pageUrl) =>
|
||||
await _requestTypeService.CreateAsync(
|
||||
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
|
||||
CancellationToken.None);
|
||||
|
||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||
|
||||
/// <summary>Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST.</summary>
|
||||
private static object Selection(
|
||||
int id,
|
||||
string position,
|
||||
string? objectProject = null,
|
||||
double vacancies = 1,
|
||||
string? submitDate = "2026-07-08T00:00:00Z",
|
||||
string? created = "2026-07-08T00:00:00Z",
|
||||
string? responsible = "Ким Наталья Юрьевна",
|
||||
string? openReason = null,
|
||||
string? openReasonDetails = null) =>
|
||||
new
|
||||
{
|
||||
Id = id,
|
||||
RequestStatus = "Выполнение",
|
||||
Position = position,
|
||||
DepartmentName = "офис",
|
||||
ObjectProject = objectProject,
|
||||
// Number-колонка приходит дробной («1.0») — тест держит её такой же.
|
||||
OpenVacanciesCount = vacancies,
|
||||
SubmitDate = submitDate,
|
||||
Created = created,
|
||||
OpenReason = openReason,
|
||||
OpenReasonDetails = openReasonDetails,
|
||||
Responsible = new { Title = responsible }
|
||||
};
|
||||
|
||||
/// <summary>Строка фазы «Исполнение» из списка «Заявка на подбор — согласование».</summary>
|
||||
private static object Execution(
|
||||
int ticketId,
|
||||
string status = "Выполнено",
|
||||
string? date = "2026-07-22T10:00:00Z",
|
||||
double stepOrder = 1,
|
||||
string? executor = "Сергеева Надежда Ивановна") =>
|
||||
new
|
||||
{
|
||||
Id = ticketId * 100 + (int)stepOrder,
|
||||
TicketId = ticketId,
|
||||
ApprovalStatus = status,
|
||||
StepOrder = stepOrder,
|
||||
Date = date,
|
||||
TansuSiteApprover = new { Title = executor }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в
|
||||
/// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по
|
||||
/// одному Id — в разных партициях Id повторяются.
|
||||
/// </summary>
|
||||
private static object Hire(
|
||||
int selectionId,
|
||||
string selectionListUrl,
|
||||
string status = "Закрыта",
|
||||
string? hireDate = "2026-08-17T00:00:00Z",
|
||||
string? replaced = null,
|
||||
string? candidate = "Шакенов Анель") =>
|
||||
new
|
||||
{
|
||||
Id = selectionId * 10,
|
||||
RequestStatus = status,
|
||||
SelectionRequestId = (double)selectionId,
|
||||
SelectionRequestListId = selectionListUrl,
|
||||
CandidateName = candidate,
|
||||
ReplacedEmployee = replaced,
|
||||
HireDate = hireDate
|
||||
};
|
||||
|
||||
private async Task<(VacancyReportService Service, Mock<ISharePointClient> SharePoint)> BuildAsync(
|
||||
string name,
|
||||
IReadOnlyList<object> selections,
|
||||
IReadOnlyList<object>? executions = null,
|
||||
IReadOnlyList<object>? hires = null)
|
||||
{
|
||||
var selectionList = SelectionListUrl(name);
|
||||
var hireList = HireListUrl(name);
|
||||
var selectionPage = $"/подбор-{name}";
|
||||
var hirePage = $"/приём-{name}";
|
||||
|
||||
await CreateTypeAsync($"Подбор {name}", selectionList, selectionPage);
|
||||
await CreateTypeAsync($"Приём {name}", hireList, hirePage);
|
||||
|
||||
var sharePoint = new Mock<ISharePointClient>();
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(
|
||||
SiteUrl, selectionList, It.Is<string>(q => q.Contains("OpenVacanciesCount")),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(selections.Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DataListTitle);
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListTitleAsync(
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("Исполнение")),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((executions ?? []).Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(
|
||||
SiteUrl, hireList, It.Is<string>(q => q.Contains("SelectionRequestId")),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((hires ?? []).Select(Json).ToList());
|
||||
|
||||
var service = new VacancyReportService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||
Options.Create(new ReportsOptions
|
||||
{
|
||||
RecruitingSelectionPageUrl = selectionPage,
|
||||
RecruitingHirePageUrl = hirePage
|
||||
}),
|
||||
NullLogger<VacancyReportService>.Instance);
|
||||
|
||||
return (service, sharePoint);
|
||||
}
|
||||
|
||||
private static VacancyReportRow SingleRow(VacancyReport report) =>
|
||||
report.Groups.ShouldHaveSingleItem().Rows.ShouldHaveSingleItem();
|
||||
|
||||
[Fact(DisplayName = "Закрытая вакансия: приём даёт число закрытых, «да» и дату выхода")]
|
||||
public async Task ClosedVacancy_FilledFromHire()
|
||||
{
|
||||
const string name = "closed";
|
||||
var (service, _) = await BuildAsync(
|
||||
name,
|
||||
[Selection(1, "Зам нач.юр.отдела")],
|
||||
[Execution(1, date: "2026-07-08T10:00:00Z")],
|
||||
[Hire(1, SelectionListUrl(name))]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var row = SingleRow(result.Value);
|
||||
row.Position.ShouldBe("Зам нач.юр.отдела");
|
||||
row.Department.ShouldBe("офис");
|
||||
row.VacanciesCount.ShouldBe(1);
|
||||
row.ClosedCount.ShouldBe(1);
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
|
||||
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||||
row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||||
row.Initiator.ShouldBe("Ким Н.Ю.");
|
||||
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
||||
var line = row.Lines.ShouldHaveSingleItem();
|
||||
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
|
||||
line.CandidateName.ShouldBe("Шакенов Анель");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
||||
public async Task Hire_FromOtherPartition_NotMatched()
|
||||
{
|
||||
var (service, _) = await BuildAsync(
|
||||
"otherPartition",
|
||||
[Selection(1, "Зам нач.юр.отдела")],
|
||||
[Execution(1)],
|
||||
[Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
var row = SingleRow(result.Value);
|
||||
row.ClosedCount.ShouldBe(0);
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
|
||||
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
||||
public async Task PartiallyClosed()
|
||||
{
|
||||
const string name = "partial";
|
||||
var (service, _) = await BuildAsync(
|
||||
name,
|
||||
[Selection(1, "Логист", vacancies: 2)],
|
||||
[Execution(1, status: "На исполнении", date: null)],
|
||||
[Hire(1, SelectionListUrl(name))]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
var row = SingleRow(result.Value);
|
||||
row.VacanciesCount.ShouldBe(2);
|
||||
row.ClosedCount.ShouldBe(1);
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")]
|
||||
public async Task ExecutionInProgress_NoClosingDate()
|
||||
{
|
||||
var (service, _) = await BuildAsync(
|
||||
"inprogress",
|
||||
[Selection(1, "Начальник ОИД")],
|
||||
[
|
||||
Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1),
|
||||
Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна")
|
||||
]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
var row = SingleRow(result.Value);
|
||||
row.ClosingDate.ShouldBeNull();
|
||||
// Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он.
|
||||
row.Recruiter.ShouldBe("Ким Дания Сериковна");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")]
|
||||
public async Task RejectedHire_DoesNotClose()
|
||||
{
|
||||
const string name = "rejected";
|
||||
var (service, _) = await BuildAsync(
|
||||
name,
|
||||
[Selection(1, "Юрист")],
|
||||
[Execution(1)],
|
||||
[Hire(1, SelectionListUrl(name), status: "Отклонено")]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
var row = SingleRow(result.Value);
|
||||
row.ClosedCount.ShouldBe(0);
|
||||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
|
||||
public async Task SeveralHires_OneLineEach()
|
||||
{
|
||||
const string name = "several";
|
||||
var list = SelectionListUrl(name);
|
||||
var (service, _) = await BuildAsync(
|
||||
name,
|
||||
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||
[Execution(1)],
|
||||
[
|
||||
// Намеренно НЕ в порядке дат — проверяем сортировку строк.
|
||||
Hire(1, list, hireDate: "2026-08-25T00:00:00Z", candidate: "Бутиев Азамат"),
|
||||
Hire(1, list, hireDate: "2026-08-05T00:00:00Z", candidate: "Дулин Евгений")
|
||||
]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
var row = SingleRow(result.Value);
|
||||
row.ClosedCount.ShouldBe(2);
|
||||
// Вакансия одна, но строк на листе две — по приёму на каждую.
|
||||
row.Lines.Count.ShouldBe(2);
|
||||
row.Lines.Select(l => l.StartDate).ShouldBe([new DateOnly(2026, 8, 5), new DateOnly(2026, 8, 25)]);
|
||||
row.Lines.Select(l => l.CandidateName).ShouldBe(["Дулин Евгений", "Бутиев Азамат"]);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Отклонённый приём не даёт строки — кандидата в отчёте быть не должно")]
|
||||
public async Task RejectedHire_ProducesNoLine()
|
||||
{
|
||||
const string name = "rejectedLine";
|
||||
var list = SelectionListUrl(name);
|
||||
var (service, _) = await BuildAsync(
|
||||
name,
|
||||
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||
[Execution(1)],
|
||||
[
|
||||
Hire(1, list, candidate: "Принятый Кандидат"),
|
||||
Hire(1, list, status: "Отклонено", candidate: "Отклонённый Кандидат")
|
||||
]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
var row = SingleRow(result.Value);
|
||||
row.ClosedCount.ShouldBe(1);
|
||||
row.Lines.ShouldHaveSingleItem().CandidateName.ShouldBe("Принятый Кандидат");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
||||
public async Task SubmitDateEmpty_FallsBackToCreated()
|
||||
{
|
||||
var (service, _) = await BuildAsync(
|
||||
"created",
|
||||
[Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")]
|
||||
public async Task VacanciesCountEmpty_IsOne()
|
||||
{
|
||||
var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
SingleRow(result.Value).VacanciesCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")]
|
||||
public async Task Groups_UnnamedFirstThenAlphabetical()
|
||||
{
|
||||
var (service, _) = await BuildAsync(
|
||||
"groups",
|
||||
[
|
||||
Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"),
|
||||
Selection(2, "Логист"),
|
||||
Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау")
|
||||
]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")]
|
||||
public async Task ReplacedEmployee_OnlyForReplacementReason()
|
||||
{
|
||||
var (service, _) = await BuildAsync(
|
||||
"replaced",
|
||||
[
|
||||
Selection(1, "Начальник геодезии",
|
||||
openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"),
|
||||
Selection(2, "Инженер ВиВ",
|
||||
openReason: "Новая штатная единица", openReasonDetails: "по расширению штата")
|
||||
]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
|
||||
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
|
||||
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
||||
rows.Single(r => r.Position == "Начальник геодезии")
|
||||
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Серик");
|
||||
rows.Single(r => r.Position == "Инженер ВиВ")
|
||||
.Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "«Взамен кого» из заявки на приём важнее причины открытия вакансии")]
|
||||
public async Task ReplacedEmployee_HireWins()
|
||||
{
|
||||
const string name = "replacedHire";
|
||||
var (service, _) = await BuildAsync(
|
||||
name,
|
||||
[Selection(1, "Юрист", openReason: "Замена уволенного сотрудника", openReasonDetails: "Из заявки")],
|
||||
[Execution(1)],
|
||||
[Hire(1, SelectionListUrl(name), replaced: "Из приёма")]);
|
||||
|
||||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
SingleRow(result.Value).Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Из приёма");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||||
public async Task InvertedPeriod_Fails()
|
||||
{
|
||||
var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]);
|
||||
|
||||
var result = await service.GetAsync(To, From, CancellationToken.None);
|
||||
|
||||
result.IsFailed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")]
|
||||
public async Task PeriodFilter_PassedToSharePoint()
|
||||
{
|
||||
const string name = "filter";
|
||||
var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]);
|
||||
|
||||
await service.GetAsync(From, To, CancellationToken.None);
|
||||
|
||||
sharePoint.Verify(
|
||||
x => x.GetItemsByListUrlAsync(
|
||||
SiteUrl,
|
||||
SelectionListUrl(name),
|
||||
It.Is<string>(q =>
|
||||
q.Contains("SubmitDate ge datetime'2026-07-01T00:00:00Z'")
|
||||
// Верхняя граница — начало следующего дня и строгое lt: иначе
|
||||
// заявки за сам последний день периода отсеклись бы.
|
||||
&& q.Contains("SubmitDate lt datetime'2026-09-01T00:00:00Z'")
|
||||
&& q.Contains("SubmitDate eq null and Created ge")),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
using NPOI.SS.UserModel;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
|
||||
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
|
||||
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
|
||||
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
|
||||
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
|
||||
/// </summary>
|
||||
public sealed class VacancyReportWorkbookTests
|
||||
{
|
||||
private static VacancyReportRow Row(
|
||||
string position,
|
||||
int vacancies = 1,
|
||||
int closed = 0,
|
||||
DateOnly? submitDate = null,
|
||||
DateOnly? closingDate = null,
|
||||
DateOnly? startDate = null,
|
||||
string? recruiter = "Сергеева Надежда Ивановна",
|
||||
IReadOnlyList<VacancyReportLine>? lines = null) =>
|
||||
new(
|
||||
position,
|
||||
"офис",
|
||||
vacancies,
|
||||
closed,
|
||||
submitDate ?? new DateOnly(2026, 7, 8),
|
||||
closingDate,
|
||||
"Ким Н.Ю.",
|
||||
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||||
recruiter,
|
||||
// По умолчанию одна строка — вакансия без приёмов либо с одним;
|
||||
// тесты многострочности передают lines явно.
|
||||
lines ?? [new VacancyReportLine(null, startDate, null)]);
|
||||
|
||||
/// <summary>Строка листа в пределах вакансии — то, что различается между приёмами.</summary>
|
||||
private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) =>
|
||||
new(candidate, startDate, replaced);
|
||||
|
||||
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
|
||||
{
|
||||
var report = new VacancyReport(from, to, groups);
|
||||
var bytes = VacancyReportWorkbook.Build(report);
|
||||
return new XSSFWorkbook(new MemoryStream(bytes));
|
||||
}
|
||||
|
||||
private static ISheet BuildListSheet(params VacancyReportGroup[] groups) =>
|
||||
BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups)
|
||||
.GetSheet("Список вакансий (все)");
|
||||
|
||||
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
|
||||
private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||
sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1);
|
||||
|
||||
private static string StringAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||
CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? "";
|
||||
|
||||
private static int IntAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||
(int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста"));
|
||||
|
||||
private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||
CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value;
|
||||
|
||||
private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol)
|
||||
{
|
||||
var cell = CellAt(sheet, excelRow, excelCol);
|
||||
return cell is null || cell.CellType == CellType.Blank;
|
||||
}
|
||||
|
||||
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
|
||||
private static string FormulaAt(ISheet sheet, int excelRow, int excelCol)
|
||||
{
|
||||
var cell = CellAt(sheet, excelRow, excelCol);
|
||||
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")]
|
||||
public void Header_MatchesSample()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||
|
||||
StringAt(sheet, 1, 2).ShouldBe("№");
|
||||
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||||
StringAt(sheet, 1, 4).ShouldBe("Подразделение");
|
||||
StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий");
|
||||
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
|
||||
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
|
||||
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
|
||||
StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника");
|
||||
StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки");
|
||||
StringAt(sheet, 1, 15).ShouldBe("Комментарии");
|
||||
StringAt(sheet, 1, 17).ShouldBe("Рекрутер");
|
||||
IsEmptyAt(sheet, 1, 18).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")]
|
||||
public void Header_DateColumns_SameStyleAsRestOfHeader()
|
||||
{
|
||||
// Регрессия: даты когда-то получали формат через SetDefaultColumnStyle,
|
||||
// а он в NPOI переписывает стиль УЖЕ существующих ячеек колонки —
|
||||
// включая шапку, которая от этого лишалась заливки (H1/I1/K1 получали
|
||||
// DataDate вместо Header). Формат даты теперь ставится только на сами
|
||||
// ячейки с датами (см. SetDate), шапки колонок с датами он не касается.
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||
|
||||
// NPOI отдаёт на каждый вызов CellStyle новую обёртку — сравнивать
|
||||
// нужно индекс стиля в книге, а не ссылки на объекты.
|
||||
var headerStyleIndex = CellAt(sheet, 1, 3)!.CellStyle.Index; // «Вакансия» — заведомо обычная шапка.
|
||||
foreach (var col in (int[])[8, 9, 11]) // H «Дата подачи заявки», I «Дата закрытия», K «Дата выхода»
|
||||
CellAt(sheet, 1, col)!.CellStyle.Index.ShouldBe(headerStyleIndex);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
|
||||
public void DataRow_Layout()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
|
||||
submitDate: new DateOnly(2026, 3, 30),
|
||||
closingDate: new DateOnly(2026, 7, 8),
|
||||
startDate: new DateOnly(2026, 8, 17))
|
||||
]));
|
||||
|
||||
IntAt(sheet, 2, 1).ShouldBe(1);
|
||||
StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела");
|
||||
IntAt(sheet, 2, 5).ShouldBe(1);
|
||||
IntAt(sheet, 2, 6).ShouldBe(1);
|
||||
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
|
||||
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
|
||||
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17));
|
||||
StringAt(sheet, 2, 13).ShouldBe("Ким Н.Ю.");
|
||||
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||||
IsEmptyAt(sheet, 2, 15).ShouldBeTrue();
|
||||
StringAt(sheet, 2, 16).ShouldBe("да");
|
||||
StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")]
|
||||
public void SeveralHires_OneRowEachWithMergedCommonCells()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||
Row("Разнорабочий", vacancies: 4, closed: 4,
|
||||
submitDate: new DateOnly(2026, 7, 10),
|
||||
lines: [
|
||||
Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)),
|
||||
Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)),
|
||||
Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.")
|
||||
])
|
||||
]));
|
||||
|
||||
// Построчные колонки — своё значение в каждой строке.
|
||||
StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович");
|
||||
StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич");
|
||||
StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович");
|
||||
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19));
|
||||
DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21));
|
||||
DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25));
|
||||
StringAt(sheet, 4, 14).ShouldBe("Оспанов К.");
|
||||
|
||||
// Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel
|
||||
// хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах.
|
||||
StringAt(sheet, 2, 3).ShouldBe("Разнорабочий");
|
||||
IsEmptyAt(sheet, 3, 3).ShouldBeTrue();
|
||||
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||||
IntAt(sheet, 2, 5).ShouldBe(4);
|
||||
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||||
|
||||
// И сами объединения заведены — иначе продолжения выглядели бы пустыми.
|
||||
var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList();
|
||||
merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2);
|
||||
// Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы.
|
||||
merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName
|
||||
merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")]
|
||||
public void SingleLine_NoMergedRegions()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||
|
||||
sheet.NumMergedRegions.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")]
|
||||
public void Totals_CountVacancyOnce_NotPerHire()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||
Row("Разнорабочий", vacancies: 4, closed: 4, lines: [
|
||||
Line("Первый", new DateOnly(2026, 8, 19)),
|
||||
Line("Второй", new DateOnly(2026, 8, 21))
|
||||
]),
|
||||
Row("Логист", vacancies: 1, closed: 0)
|
||||
]));
|
||||
|
||||
// Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й.
|
||||
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||||
// Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение),
|
||||
// поэтому SUM даёт 4+1=5, а не 4+4+1.
|
||||
IsEmptyAt(sheet, 3, 5).ShouldBeTrue();
|
||||
IntAt(sheet, 4, 5).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||||
public void DerivedColumns_AreFormulas()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||
Row("Логист", vacancies: 3, closed: 1,
|
||||
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
|
||||
]));
|
||||
|
||||
FormulaAt(sheet, 2, 7).ShouldBe("E2-F2");
|
||||
FormulaAt(sheet, 2, 10).ShouldBe("I2-H2");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
|
||||
public void LeadTime_EmptyWithoutClosingDate()
|
||||
{
|
||||
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||
|
||||
FormulaAt(sheet, 2, 10).ShouldBeEmpty();
|
||||
IsEmptyAt(sheet, 2, 10).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
|
||||
public void GroupSeparator_RestartsNumbering()
|
||||
{
|
||||
var sheet = BuildListSheet(
|
||||
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
|
||||
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
|
||||
|
||||
// Верхний блок без объекта — сразу после шапки, без разделителя.
|
||||
StringAt(sheet, 2, 3).ShouldBe("Логист");
|
||||
StringAt(sheet, 3, 3).ShouldBe("Юрист");
|
||||
|
||||
StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ");
|
||||
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||||
|
||||
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
|
||||
IntAt(sheet, 5, 1).ShouldBe(3);
|
||||
IntAt(sheet, 5, 2).ShouldBe(1);
|
||||
IntAt(sheet, 6, 1).ShouldBe(4);
|
||||
IntAt(sheet, 6, 2).ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
|
||||
public void Totals_SumWholeDataRange()
|
||||
{
|
||||
var sheet = BuildListSheet(
|
||||
new VacancyReportGroup("", [Row("Логист")]),
|
||||
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
|
||||
|
||||
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
|
||||
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||||
FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)");
|
||||
FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
|
||||
public void EmptyReport_HeaderOnly()
|
||||
{
|
||||
var sheet = BuildListSheet();
|
||||
|
||||
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||||
IsEmptyAt(sheet, 2, 5).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
|
||||
public void FileName_CarriesPeriod()
|
||||
{
|
||||
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []);
|
||||
|
||||
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
|
||||
}
|
||||
|
||||
// ==================== Лист «Результаты» ====================
|
||||
|
||||
private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) =>
|
||||
Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter);
|
||||
|
||||
[Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")]
|
||||
public void SummarySheet_HorizontalTable()
|
||||
{
|
||||
var workbook = BuildWorkbook(
|
||||
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||
new VacancyReportGroup("", [
|
||||
RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40%
|
||||
RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100%
|
||||
]));
|
||||
var sheet = workbook.GetSheet("Результаты");
|
||||
|
||||
StringAt(sheet, 1, 1).ShouldBe("результаты");
|
||||
|
||||
// Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5).
|
||||
StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий");
|
||||
StringAt(sheet, 2, 2).ShouldBe("Надежда");
|
||||
StringAt(sheet, 2, 3).ShouldBe("Алтынай");
|
||||
|
||||
StringAt(sheet, 3, 1).ShouldBe("Всего");
|
||||
IntAt(sheet, 3, 2).ShouldBe(10);
|
||||
IntAt(sheet, 3, 3).ShouldBe(5);
|
||||
|
||||
StringAt(sheet, 4, 1).ShouldBe("в работе (в%)");
|
||||
CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001);
|
||||
CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001);
|
||||
|
||||
StringAt(sheet, 5, 1).ShouldBe("закрыто");
|
||||
CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001);
|
||||
CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")]
|
||||
public void SummarySheet_UnassignedRecruiterLast()
|
||||
{
|
||||
var workbook = BuildWorkbook(
|
||||
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||
new VacancyReportGroup("", [
|
||||
RowFor("Алтынай", vacancies: 1, closed: 0),
|
||||
Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null),
|
||||
]));
|
||||
var sheet = workbook.GetSheet("Результаты");
|
||||
|
||||
// «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на
|
||||
// то, что её объём (100) больше, чем у Алтынай (1): это не участник
|
||||
// рейтинга, а технический хвост.
|
||||
StringAt(sheet, 2, 2).ShouldBe("Алтынай");
|
||||
StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")]
|
||||
public void SummarySheet_VerticalTables()
|
||||
{
|
||||
var workbook = BuildWorkbook(
|
||||
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||
new VacancyReportGroup("", [
|
||||
RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе
|
||||
RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт
|
||||
]));
|
||||
var sheet = workbook.GetSheet("Результаты");
|
||||
|
||||
// Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк
|
||||
// горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%.
|
||||
StringAt(sheet, 7, 1).ShouldBe("Общий объем работ");
|
||||
IntAt(sheet, 7, 2).ShouldBe(4);
|
||||
StringAt(sheet, 8, 1).ShouldBe("Надежда");
|
||||
CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001);
|
||||
StringAt(sheet, 9, 1).ShouldBe("Алтынай");
|
||||
CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001);
|
||||
|
||||
// Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда
|
||||
// не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%.
|
||||
StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе");
|
||||
IntAt(sheet, 11, 2).ShouldBe(3);
|
||||
StringAt(sheet, 12, 1).ShouldBe("Надежда");
|
||||
CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001);
|
||||
StringAt(sheet, 13, 1).ShouldBe("Алтынай");
|
||||
CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")]
|
||||
public void SummarySheet_HasTwoPieCharts()
|
||||
{
|
||||
var workbook = BuildWorkbook(
|
||||
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||
new VacancyReportGroup("", [
|
||||
RowFor("Надежда", vacancies: 3, closed: 1),
|
||||
RowFor("Алтынай", vacancies: 2, closed: 0),
|
||||
]));
|
||||
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
|
||||
|
||||
var charts = sheet.GetDrawingPatriarch()?.GetCharts();
|
||||
charts.ShouldNotBeNull();
|
||||
charts!.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")]
|
||||
public void SummarySheet_EmptyReport_NoTablesOrCharts()
|
||||
{
|
||||
var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31));
|
||||
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
|
||||
|
||||
sheet.GetDrawingPatriarch().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user