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

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/20
This commit was merged in pull request #20.
This commit is contained in:
2026-08-31 11:00:09 +00:00
parent 95d460c80a
commit e2b6227015
16 changed files with 2198 additions and 0 deletions
@@ -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>
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
/// конвенции «&lt;Форма&gt; &lt;N&gt; — данные|согласование»: 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" />