Отчет по вакансиям #20
@@ -10,6 +10,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||||
|
<PackageVersion Include="ClosedXML" Version="0.105.1" />
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.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;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
|||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||||
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
|
||||||
|
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
|
||||||
|
|
||||||
builder.Services.AddHangfire(configuration => configuration
|
builder.Services.AddHangfire(configuration => configuration
|
||||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using FluentValidation;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
@@ -19,6 +20,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddScoped<ListPartitionService>();
|
services.AddScoped<ListPartitionService>();
|
||||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||||
services.AddScoped<CreateNextListPartitionJob>();
|
services.AddScoped<CreateNextListPartitionJob>();
|
||||||
|
services.AddScoped<VacancyReportService>();
|
||||||
services.AddScoped<TemplateRouteService>();
|
services.AddScoped<TemplateRouteService>();
|
||||||
services.AddScoped<TemplateRouteStepService>();
|
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,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>
|
||||||
|
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
|
||||||
|
/// конвенции «<Форма> <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,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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter. -->
|
||||||
|
<PackageReference Include="ClosedXML" />
|
||||||
<PackageReference Include="FluentResults" />
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="FluentValidation" />
|
<PackageReference Include="FluentValidation" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
|||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <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) =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = selectionId * 10,
|
||||||
|
RequestStatus = status,
|
||||||
|
SelectionRequestId = (double)selectionId,
|
||||||
|
SelectionRequestListId = selectionListUrl,
|
||||||
|
CandidateName = "Шакенов Анель",
|
||||||
|
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.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
|
||||||
|
row.Initiator.ShouldBe("Ким Н.Ю.");
|
||||||
|
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
||||||
|
public async Task Hire_FromOtherPartition_NotMatched()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"otherPartition",
|
||||||
|
[Selection(1, "Зам нач.юр.отдела")],
|
||||||
|
[Execution(1)],
|
||||||
|
[Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(0);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||||
|
row.StartDate.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
||||||
|
public async Task PartiallyClosed()
|
||||||
|
{
|
||||||
|
const string name = "partial";
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Логист", vacancies: 2)],
|
||||||
|
[Execution(1, status: "На исполнении", date: null)],
|
||||||
|
[Hire(1, SelectionListUrl(name))]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.VacanciesCount.ShouldBe(2);
|
||||||
|
row.ClosedCount.ShouldBe(1);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")]
|
||||||
|
public async Task ExecutionInProgress_NoClosingDate()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"inprogress",
|
||||||
|
[Selection(1, "Начальник ОИД")],
|
||||||
|
[
|
||||||
|
Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1),
|
||||||
|
Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosingDate.ShouldBeNull();
|
||||||
|
// Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он.
|
||||||
|
row.Recruiter.ShouldBe("Ким Дания Сериковна");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")]
|
||||||
|
public async Task RejectedHire_DoesNotClose()
|
||||||
|
{
|
||||||
|
const string name = "rejected";
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Юрист")],
|
||||||
|
[Execution(1)],
|
||||||
|
[Hire(1, SelectionListUrl(name), status: "Отклонено")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(0);
|
||||||
|
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«Дата выхода» — самая поздняя среди нескольких приёмов")]
|
||||||
|
public async Task StartDate_IsLatestHireDate()
|
||||||
|
{
|
||||||
|
const string name = "several";
|
||||||
|
var list = SelectionListUrl(name);
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
name,
|
||||||
|
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||||||
|
[Execution(1)],
|
||||||
|
[
|
||||||
|
Hire(1, list, hireDate: "2026-08-05T00:00:00Z"),
|
||||||
|
Hire(1, list, hireDate: "2026-08-25T00:00:00Z")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = SingleRow(result.Value);
|
||||||
|
row.ClosedCount.ShouldBe(2);
|
||||||
|
row.StartDate.ShouldBe(new DateOnly(2026, 8, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
||||||
|
public async Task SubmitDateEmpty_FallsBackToCreated()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"created",
|
||||||
|
[Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")]
|
||||||
|
public async Task VacanciesCountEmpty_IsOne()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
SingleRow(result.Value).VacanciesCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")]
|
||||||
|
public async Task Groups_UnnamedFirstThenAlphabetical()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"groups",
|
||||||
|
[
|
||||||
|
Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"),
|
||||||
|
Selection(2, "Логист"),
|
||||||
|
Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")]
|
||||||
|
public async Task ReplacedEmployee_OnlyForReplacementReason()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync(
|
||||||
|
"replaced",
|
||||||
|
[
|
||||||
|
Selection(1, "Начальник геодезии",
|
||||||
|
openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"),
|
||||||
|
Selection(2, "Инженер ВиВ",
|
||||||
|
openReason: "Новая штатная единица", openReasonDetails: "по расширению штата")
|
||||||
|
]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
||||||
|
rows.Single(r => r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик");
|
||||||
|
rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||||||
|
public async Task InvertedPeriod_Fails()
|
||||||
|
{
|
||||||
|
var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]);
|
||||||
|
|
||||||
|
var result = await service.GetAsync(To, From, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")]
|
||||||
|
public async Task PeriodFilter_PassedToSharePoint()
|
||||||
|
{
|
||||||
|
const string name = "filter";
|
||||||
|
var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]);
|
||||||
|
|
||||||
|
await service.GetAsync(From, To, CancellationToken.None);
|
||||||
|
|
||||||
|
sharePoint.Verify(
|
||||||
|
x => x.GetItemsByListUrlAsync(
|
||||||
|
SiteUrl,
|
||||||
|
SelectionListUrl(name),
|
||||||
|
It.Is<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители
|
||||||
|
/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл
|
||||||
|
/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется
|
||||||
|
/// именно то, что увидит 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 = "Сергеева Надежда Ивановна") =>
|
||||||
|
new(
|
||||||
|
position,
|
||||||
|
"офис",
|
||||||
|
vacancies,
|
||||||
|
closed,
|
||||||
|
submitDate ?? new DateOnly(2026, 7, 8),
|
||||||
|
closingDate,
|
||||||
|
startDate,
|
||||||
|
"Ким Н.Ю.",
|
||||||
|
null,
|
||||||
|
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||||||
|
recruiter);
|
||||||
|
|
||||||
|
private static IXLWorksheet Build(params VacancyReportGroup[] groups)
|
||||||
|
{
|
||||||
|
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups);
|
||||||
|
var bytes = VacancyReportWorkbook.Build(report);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(bytes);
|
||||||
|
// Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML
|
||||||
|
// держит уже разобранную модель в памяти, а не ленивую ссылку на поток.
|
||||||
|
var workbook = new XLWorkbook(stream);
|
||||||
|
return workbook.Worksheet("Список вакансий (все)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")]
|
||||||
|
public void Header_MatchesSample()
|
||||||
|
{
|
||||||
|
var sheet = Build(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
sheet.Cell(1, 2).GetString().ShouldBe("№");
|
||||||
|
sheet.Cell(1, 3).GetString().ShouldBe("Вакансия");
|
||||||
|
sheet.Cell(1, 4).GetString().ShouldBe("Подразделение");
|
||||||
|
sheet.Cell(1, 5).GetString().ShouldBe("Кол-во вакансий");
|
||||||
|
sheet.Cell(1, 8).GetString().ShouldBe("Дата подачи заявки");
|
||||||
|
sheet.Cell(1, 9).GetString().ShouldBe("Дата закрытия вакансии");
|
||||||
|
sheet.Cell(1, 11).GetString().ShouldBe("Дата выхода");
|
||||||
|
sheet.Cell(1, 12).GetString().ShouldBe("Инициатор заявки");
|
||||||
|
sheet.Cell(1, 14).GetString().ShouldBe("Комментарии");
|
||||||
|
sheet.Cell(1, 16).GetString().ShouldBe("Рекрутер");
|
||||||
|
sheet.Cell(1, 17).IsEmpty().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
|
||||||
|
public void DataRow_Layout()
|
||||||
|
{
|
||||||
|
var sheet = Build(new VacancyReportGroup("", [
|
||||||
|
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
|
||||||
|
submitDate: new DateOnly(2026, 3, 30),
|
||||||
|
closingDate: new DateOnly(2026, 7, 8),
|
||||||
|
startDate: new DateOnly(2026, 8, 17))
|
||||||
|
]));
|
||||||
|
|
||||||
|
sheet.Cell(2, 1).GetValue<int>().ShouldBe(1);
|
||||||
|
sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела");
|
||||||
|
sheet.Cell(2, 5).GetValue<int>().ShouldBe(1);
|
||||||
|
sheet.Cell(2, 6).GetValue<int>().ShouldBe(1);
|
||||||
|
sheet.Cell(2, 8).GetDateTime().ShouldBe(new DateTime(2026, 3, 30));
|
||||||
|
sheet.Cell(2, 9).GetDateTime().ShouldBe(new DateTime(2026, 7, 8));
|
||||||
|
sheet.Cell(2, 11).GetDateTime().ShouldBe(new DateTime(2026, 8, 17));
|
||||||
|
sheet.Cell(2, 12).GetString().ShouldBe("Ким Н.Ю.");
|
||||||
|
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||||||
|
sheet.Cell(2, 14).IsEmpty().ShouldBeTrue();
|
||||||
|
sheet.Cell(2, 15).GetString().ShouldBe("да");
|
||||||
|
sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||||||
|
public void DerivedColumns_AreFormulas()
|
||||||
|
{
|
||||||
|
var sheet = Build(new VacancyReportGroup("", [
|
||||||
|
Row("Логист", vacancies: 3, closed: 1,
|
||||||
|
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
|
||||||
|
]));
|
||||||
|
|
||||||
|
sheet.Cell(2, 7).FormulaA1.ShouldBe("E2-F2");
|
||||||
|
sheet.Cell(2, 10).FormulaA1.ShouldBe("I2-H2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
|
||||||
|
public void LeadTime_EmptyWithoutClosingDate()
|
||||||
|
{
|
||||||
|
var sheet = Build(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
|
sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty();
|
||||||
|
sheet.Cell(2, 10).IsEmpty().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
|
||||||
|
public void GroupSeparator_RestartsNumbering()
|
||||||
|
{
|
||||||
|
var sheet = Build(
|
||||||
|
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
|
||||||
|
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
|
||||||
|
|
||||||
|
// Верхний блок без объекта — сразу после шапки, без разделителя.
|
||||||
|
sheet.Cell(2, 3).GetString().ShouldBe("Логист");
|
||||||
|
sheet.Cell(3, 3).GetString().ShouldBe("Юрист");
|
||||||
|
|
||||||
|
sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ");
|
||||||
|
sheet.Cell(4, 3).IsEmpty().ShouldBeTrue();
|
||||||
|
|
||||||
|
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
|
||||||
|
sheet.Cell(5, 1).GetValue<int>().ShouldBe(3);
|
||||||
|
sheet.Cell(5, 2).GetValue<int>().ShouldBe(1);
|
||||||
|
sheet.Cell(6, 1).GetValue<int>().ShouldBe(4);
|
||||||
|
sheet.Cell(6, 2).GetValue<int>().ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
|
||||||
|
public void Totals_SumWholeDataRange()
|
||||||
|
{
|
||||||
|
var sheet = Build(
|
||||||
|
new VacancyReportGroup("", [Row("Логист")]),
|
||||||
|
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
|
||||||
|
|
||||||
|
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
|
||||||
|
sheet.Cell(5, 5).FormulaA1.ShouldBe("SUM(E2:E4)");
|
||||||
|
sheet.Cell(5, 6).FormulaA1.ShouldBe("SUM(F2:F4)");
|
||||||
|
sheet.Cell(5, 7).FormulaA1.ShouldBe("SUM(G2:G4)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
|
||||||
|
public void EmptyReport_HeaderOnly()
|
||||||
|
{
|
||||||
|
var sheet = Build();
|
||||||
|
|
||||||
|
sheet.Cell(1, 3).GetString().ShouldBe("Вакансия");
|
||||||
|
sheet.Cell(2, 5).IsEmpty().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
|
||||||
|
public void FileName_CarriesPeriod()
|
||||||
|
{
|
||||||
|
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), []);
|
||||||
|
|
||||||
|
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user