feat: отчёт по вакансиям в xlsx за период

GET api/reports/vacancies?from=&to= отдаёт лист «Список вакансий (все)»
по образцу ручного отчёта HR: те же шестнадцать колонок, разделители по
объектам, двойная нумерация, итоги формулами.

Данные — из двух форм портала: «Заявка на подбор» даёт вакансию,
«Приём на работу» — её закрытие (связь по SelectionRequestId +
SelectionRequestListId). Рекрутёр и дата закрытия берутся из фазы
«Исполнение» парного списка «— согласование»: в самой заявке их нет.

Период отбирает заявки по «Дате подачи заявки» с откатом на Created —
поле заполняется руками и часто пустое. Заявки на приём периодом не
ограничены: приём по вакансии из начала интервала мог оформляться уже
после его конца.

Колонка «Комментарии» остаётся пустой — в ручном отчёте это пометки
рекрутёра, которым в заявках пока негде храниться.

Генерация — ClosedXML (MIT): нужны стили, формулы и autofilter, а EPPlus
с v5 под Polyform Noncommercial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-27 18:01:24 +05:00
parent 95d460c80a
commit 24d2cd7a6a
15 changed files with 1444 additions and 0 deletions
@@ -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,51 @@
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>
/// Одна вакансия — строка листа. Колонки A/B (нумерация), G (в работе), J (срок
/// отработки) и итоги здесь не хранятся: их считает сам xlsx формулами, чтобы
/// цифры пересчитывались, если строку в файле поправят руками.
/// </summary>
public sealed record VacancyReportRow(
// C — «Вакансия»: должность из заявки на подбор.
string Position,
// D — «Подразделение» ответственного за подачу заявки.
string? Department,
// E — «Кол-во вакансий».
int VacanciesCount,
// F — «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор.
int ClosedCount,
// H — «Дата подачи заявки».
DateOnly? SubmitDate,
// I — «Дата закрытия вакансии»: дата решения по последнему шагу исполнения.
DateOnly? ClosingDate,
// K — «Дата выхода»: самая поздняя дата приёма среди принятых по этой вакансии.
DateOnly? StartDate,
// L — «Инициатор заявки».
string? Initiator,
// M — «Вместо сотрудника Ф.И.О.».
string? ReplacedEmployee,
// O — «Закрыта вакансия»: да / нет / частично.
string ClosedFlag,
// P — «Рекрутер»: исполнитель фазы исполнения заявки на подбор.
string? Recruiter);
/// <summary>Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет.</summary>
public static class VacancyClosedFlags
{
public const string Yes = "да";
public const string No = "нет";
public const string Partial = "частично";
}
@@ -0,0 +1,358 @@
using System.Globalization;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>
/// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор»
/// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на
/// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки
/// SelectionRequestId / SelectionRequestListId заявки на приём.
///
/// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного
/// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow).
/// </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 closed = hires.Count(x => x.Status != HireRequestStatus.Rejected);
return new VacancyReportRow(
row.Position ?? string.Empty,
row.DepartmentName,
row.VacanciesCount,
closed,
row.SubmitDate,
execution.ClosingDate,
hires.Select(x => x.HireDate).Where(x => x is not null).Max(),
ShortName(row.Initiator),
ReplacedEmployee(row, hires),
ClosedFlag(row.VacanciesCount, closed),
execution.Recruiter);
}
/// <summary>
/// «Вместо сотрудника» — сначала из заявки на приём (там это отдельное поле
/// «Взамен кого»), иначе из уточнения к причине открытия вакансии, и только
/// если причина — замена уволенного: при «новой штатной единице» в том же
/// поле лежит совсем другой текст.
/// </summary>
private static string? ReplacedEmployee(SelectionRow row, IReadOnlyList<HireLinkRow> hires)
{
var fromHires = hires
.Select(x => x.ReplacedEmployee)
.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
if (fromHires is not null) return fromHires;
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
}
private static string ClosedFlag(int vacancies, int closed) => closed switch
{
0 => VacancyClosedFlags.No,
_ when closed >= vacancies => VacancyClosedFlags.Yes,
_ => VacancyClosedFlags.Partial,
};
/// <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,224 @@
using ClosedXML.Excel;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>
/// Отчёт по вакансиям в виде xlsx — лист «Список вакансий (все)» по образцу
/// ручного отчёта HR: те же шестнадцать колонок A…P в том же порядке, строки
/// разделители по объектам, итоги внизу.
///
/// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное,
/// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой
/// пришлось бы править на каждое изменение состава вакансий.
///
/// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся
/// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после
/// правки строки цифры должны пересчитаться сами.
/// </summary>
public static class VacancyReportWorkbook
{
private const string SheetName = "Список вакансий (все)";
// Цвет шапки — из образца отчёта (fill FFE5E5FF), чтобы выгрузка выглядела
// привычно рядом со старым файлом.
private static readonly XLColor HeaderFill = XLColor.FromArgb(0xE5, 0xE5, 0xFF);
private static readonly XLColor GroupFill = XLColor.FromArgb(0xF2, 0xF2, 0xF2);
private const string DateFormat = "dd.mm.yyyy";
/// <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),
("Инициатор заявки", 26.9),
("Вместо сотрудника Ф.И.О.", 26.0),
("Комментарии", 51.3),
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
("Рекрутер", 23.9),
];
// Номера колонок — именами, чтобы формулы и стили не разъезжались с Columns.
private const int ColTotalNumber = 1;
private const int ColGroupNumber = 2;
private const int ColPosition = 3;
private const int ColDepartment = 4;
private const int ColVacancies = 5;
private const int ColClosed = 6;
private const int ColInProgress = 7;
private const int ColSubmitDate = 8;
private const int ColClosingDate = 9;
private const int ColLeadTime = 10;
private const int ColStartDate = 11;
private const int ColInitiator = 12;
private const int ColReplaced = 13;
private const int ColComment = 14;
private const int ColClosedFlag = 15;
private const int ColRecruiter = 16;
private const int HeaderRow = 1;
public static byte[] Build(VacancyReport report)
{
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet(SheetName);
WriteHeader(sheet);
var row = HeaderRow + 1;
var totalNumber = 0;
var firstDataRow = row;
foreach (var group in report.Groups)
{
if (group.Name.Length > 0) WriteGroupSeparator(sheet, row++, group.Name);
var groupNumber = 0;
foreach (var item in group.Rows)
WriteRow(sheet, row++, ++totalNumber, ++groupNumber, item);
}
var lastDataRow = row - 1;
// Итоги — только если есть что суммировать: SUM по пустому диапазону
// (firstDataRow > lastDataRow) дал бы формулу с перевёрнутым диапазоном.
if (lastDataRow >= firstDataRow) WriteTotals(sheet, row, firstDataRow, lastDataRow);
Finish(sheet, lastDataRow);
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
public static string FileName(VacancyReport report) =>
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
private static void WriteHeader(IXLWorksheet sheet)
{
for (var i = 0; i < Columns.Length; i++)
{
var (title, width) = Columns[i];
var column = i + 1;
sheet.Cell(HeaderRow, column).Value = title;
sheet.Column(column).Width = width;
}
var header = sheet.Range(HeaderRow, 1, HeaderRow, Columns.Length);
header.Style.Fill.BackgroundColor = HeaderFill;
header.Style.Font.Bold = true;
header.Style.Alignment.WrapText = true;
header.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
header.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
header.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
header.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
// одну строку из трёх (высота 90 — из образца отчёта).
sheet.Row(HeaderRow).Height = 90;
}
/// <summary>
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
/// начинается заново — так же, как в ручном отчёте.
/// </summary>
private static void WriteGroupSeparator(IXLWorksheet sheet, int row, string name)
{
sheet.Cell(row, ColGroupNumber).Value = name;
var range = sheet.Range(row, 1, row, Columns.Length);
range.Style.Fill.BackgroundColor = GroupFill;
range.Style.Font.Bold = true;
}
private static void WriteRow(IXLWorksheet sheet, int row, int totalNumber, int groupNumber, VacancyReportRow item)
{
sheet.Cell(row, ColTotalNumber).Value = totalNumber;
sheet.Cell(row, ColGroupNumber).Value = groupNumber;
sheet.Cell(row, ColPosition).Value = item.Position;
sheet.Cell(row, ColDepartment).Value = item.Department ?? string.Empty;
sheet.Cell(row, ColVacancies).Value = item.VacanciesCount;
sheet.Cell(row, ColClosed).Value = item.ClosedCount;
sheet.Cell(row, ColInProgress).FormulaA1 = $"={Letter(ColVacancies)}{row}-{Letter(ColClosed)}{row}";
SetDate(sheet.Cell(row, ColSubmitDate), item.SubmitDate);
SetDate(sheet.Cell(row, ColClosingDate), item.ClosingDate);
// «Срок отработки» — разница дат в днях, и только когда известны обе:
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
// дней (пустая ячейка — это 00.01.1900).
if (item.SubmitDate is not null && item.ClosingDate is not null)
{
sheet.Cell(row, ColLeadTime).FormulaA1 =
$"={Letter(ColClosingDate)}{row}-{Letter(ColSubmitDate)}{row}";
sheet.Cell(row, ColLeadTime).Style.NumberFormat.Format = "0";
}
SetDate(sheet.Cell(row, ColStartDate), item.StartDate);
sheet.Cell(row, ColInitiator).Value = item.Initiator ?? string.Empty;
sheet.Cell(row, ColReplaced).Value = item.ReplacedEmployee ?? string.Empty;
// Колонка «Комментарии» остаётся пустой намеренно: в ручном отчёте это
// пометки рекрутёра («в подборе», «на стопе»), которым в заявках сейчас
// негде храниться. Колонку оставляем, чтобы её дописывали в выгрузке.
sheet.Cell(row, ColClosedFlag).Value = item.ClosedFlag;
sheet.Cell(row, ColRecruiter).Value = item.Recruiter ?? string.Empty;
sheet.Range(row, 1, row, Columns.Length).Style.Alignment.WrapText = true;
sheet.Range(row, 1, row, Columns.Length).Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
sheet.Range(row, 1, row, Columns.Length).Style.Border.InsideBorder = XLBorderStyleValues.Thin;
}
private static void WriteTotals(IXLWorksheet sheet, int row, int firstDataRow, int lastDataRow)
{
foreach (var column in (int[])[ColVacancies, ColClosed, ColInProgress])
sheet.Cell(row, column).FormulaA1 =
$"=SUM({Letter(column)}{firstDataRow}:{Letter(column)}{lastDataRow})";
var range = sheet.Range(row, 1, row, Columns.Length);
range.Style.Font.Bold = true;
range.Style.Border.TopBorder = XLBorderStyleValues.Double;
}
/// <summary>
/// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её
/// захватывать не должен, иначе при любом фильтре итоги уезжают из вида
/// вместе с отфильтрованными строками.
/// </summary>
private static void Finish(IXLWorksheet sheet, int lastDataRow)
{
// Даты — по всей колонке, а не по заполненным ячейкам: формат должен
// остаться и у строк, которые допишут руками.
foreach (var column in (int[])[ColSubmitDate, ColClosingDate, ColStartDate])
sheet.Column(column).Style.NumberFormat.Format = DateFormat;
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
// какая колонка какая.
sheet.SheetView.FreezeRows(HeaderRow);
if (lastDataRow > HeaderRow)
sheet.Range(HeaderRow, 1, lastDataRow, Columns.Length).SetAutoFilter();
}
private static void SetDate(IXLCell cell, DateOnly? date)
{
if (date is null) return;
cell.Value = date.Value.ToDateTime(TimeOnly.MinValue);
cell.Style.NumberFormat.Format = DateFormat;
}
private static string Letter(int column) => XLHelper.GetColumnLetterFromNumber(column);
}