Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs
T
gorbenko.pavel 2e0cc93b56
Tests / frontend-test (pull_request) Successful in 1m29s
Tests / backend-test (pull_request) Successful in 2m2s
Исправить имена колонок причины открытия вакансии в отчёте по вакансиям
В списке «Заявка на подбор — данные» колонки называются VacancyOpenReason и
VacancyOpenReasonDetails; запрос с несуществующими OpenReason* падал, ошибка
партиции глушилась в лог, и отчёт возвращался пустым.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:07:29 +05:00

411 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>
/// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор»
/// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на
/// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки
/// SelectionRequestId / SelectionRequestListId заявки на приём.
///
/// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного
/// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow).
///
/// ПОЧЕМУ ЗДЕСЬ items REST ПО ПАРТИЦИЯМ, А НЕ SEARCH (разбирались 2026-08-28).
/// Search выглядит уместнее — один KQL сразу по всем партициям, и отставание
/// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не
/// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно
/// через REST):
/// • «Заявка на подбор»: VacancyOpenReason, VacancyOpenReasonDetails;
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
/// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не
/// помогает — пробовали.
///
/// Причина, если понадобится чинить: одноимённые колонки в РАЗНЫХ списках — это
/// разные поля со своими GUID, а значит и со своими crawled property
/// (ows_Position, ows_Position1, …); автосозданное managed property привязано
/// только к одному из них. Поэтому, например, PositionOWSTEXT отдаёт значения в
/// «Заявка на подбор — данные» и молчит в «Прием на работу — данные». Проверять
/// надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим
/// занимается `sp-write props` (репозиторий C:\work\git\tools), он же говорит,
/// нужно ли домапить crawled property или свойства нет вовсе.
///
/// Значит эти поля пришлось бы дочитывать построчно батчами по 100 — и на 1000
/// заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST.
/// В list view threshold (5000) REST при этом не упирается by design: партиции
/// режутся на 4900 элементов (ListPartitioning:ItemCountThreshold), то есть ни
/// один $filter не сканирует больше предела.
///
/// Вернуться к Search имеет смысл, только если в Search Schema заведут
/// работающие managed properties для перечисленных выше полей.
/// </summary>
public sealed class VacancyReportService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
IOptions<ReportsOptions> options,
ILogger<VacancyReportService> logger)
{
public async Task<Result<VacancyReport>> GetAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken)
{
if (to < from)
return Result.Fail($"Конец периода ({to:dd.MM.yyyy}) раньше его начала ({from:dd.MM.yyyy}).");
var selectionType = await FindTypeAsync(options.Value.RecruitingSelectionPageUrl, cancellationToken);
if (selectionType is null)
return Result.Fail(
$"Тип заявки «{options.Value.RecruitingSelectionPageUrl}» не настроен (нет списка или сайта) — " +
"отчёт по вакансиям строить не из чего.");
var selections = await ReadSelectionsAsync(selectionType, from, to, cancellationToken);
// Заявки на приём читаются ВСЕ, без ограничения периодом: у вакансии,
// поданной в начале интервала, приём мог оформляться уже после его конца,
// и фильтр по дате обнулил бы ей и «Дату закрытия», и «Дату выхода».
// Дальше они отбираются по ссылке на заявку на подбор.
var hireType = await FindTypeAsync(options.Value.RecruitingHirePageUrl, cancellationToken);
if (hireType is null)
logger.LogWarning(
"Тип заявки «{PageUrl}» не настроен — отчёт по вакансиям будет без закрытий",
options.Value.RecruitingHirePageUrl);
var hires = hireType is null
? (IReadOnlyList<HireLinkRow>)[]
: await ReadHiresAsync(hireType, cancellationToken);
var hiresBySelection = hires
.GroupBy(x => (x.SelectionRequestId, ListId: NormalizeListId(x.SelectionRequestListId)))
.ToDictionary(g => g.Key, g => (IReadOnlyList<HireLinkRow>)g.ToList());
return Result.Ok(new VacancyReport(from, to, Group(selections, hiresBySelection)));
}
private async Task<RequestType?> FindTypeAsync(string pageUrl, CancellationToken cancellationToken) =>
await db.RequestTypes.AsNoTracking()
.FirstOrDefaultAsync(
x => x.PageUrl == pageUrl && x.ListId != null && x.SiteUrl != null, cancellationToken);
/// <summary>
/// Заявки на подбор за период по ВСЕМ партициям типа — вакансия, уехавшая в
/// архивный список, из отчёта пропадать не должна: подбор по ней мог начаться
/// задолго до того, как для новых заявок завели следующую партицию.
/// </summary>
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionsAsync(
RequestType type, DateOnly from, DateOnly to, CancellationToken cancellationToken)
{
var result = new List<SelectionWithExecution>();
foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
{
try
{
result.AddRange(await ReadSelectionPartitionAsync(type, listId, from, to, cancellationToken));
}
catch (Exception e)
{
// Недоступная партиция (архивный список переименовали или удалили
// руками) не должна обнулять весь отчёт — в файл попадёт то, что
// прочиталось, а причина уйдёт в лог.
logger.LogError(
e, "Отчёт по вакансиям: партиция №{Number} ({ListId}) типа «{Type}» не прочитана",
number, listId, type.Name);
}
}
return result;
}
private async Task<IReadOnlyList<SelectionWithExecution>> ReadSelectionPartitionAsync(
RequestType type, string listId, DateOnly from, DateOnly to, CancellationToken cancellationToken)
{
var siteUrl = type.SiteUrl!;
var items = await sharePoint.GetItemsByListUrlAsync(
siteUrl, listId, $"{SelectionRow.Select}&{PeriodFilter(from, to)}", cancellationToken);
var rows = items
.Select(item => SelectionRow.TryParse(item, listId))
.OfType<SelectionRow>()
.ToList();
// Список согласования читаем только если в партиции вообще нашлись
// заявки за период: у пустой партиции он всё равно ничего не добавит.
if (rows.Count == 0) return [];
var executions = await ReadExecutionsAsync(siteUrl, listId, cancellationToken);
return rows
.Select(row => new SelectionWithExecution(
row, executions.TryGetValue(row.Id, out var found) ? found : ExecutionInfo.Empty))
.ToList();
}
/// <summary>
/// Строки фазы «Исполнение» партиции, свёрнутые по заявке. Заголовок списка
/// согласования выводится из ЖИВОГО заголовка списка данных, а не из url:
/// список могли переименовать руками.
/// </summary>
private async Task<Dictionary<int, ExecutionInfo>> ReadExecutionsAsync(
string siteUrl, string listId, CancellationToken cancellationToken)
{
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null)
{
logger.LogWarning(
"Отчёт по вакансиям: у списка «{List}» нет парного списка согласования — " +
"колонки «Рекрутер» и «Дата закрытия вакансии» останутся пустыми",
dataListTitle);
return [];
}
var items = await sharePoint.GetItemsByListTitleAsync(
siteUrl, approvalListTitle, ExecutionRow.Select, cancellationToken);
return items
.Select(ExecutionRow.TryParse)
.OfType<ExecutionRow>()
.GroupBy(x => x.TicketId)
.ToDictionary(g => g.Key, ExecutionInfo.From);
}
private async Task<IReadOnlyList<HireLinkRow>> ReadHiresAsync(
RequestType type, CancellationToken cancellationToken)
{
var result = new List<HireLinkRow>();
foreach (var (listId, number) in await PartitionsOfAsync(type, cancellationToken))
{
try
{
var items = await sharePoint.GetItemsByListUrlAsync(
type.SiteUrl!, listId, HireLinkRow.Select, cancellationToken);
result.AddRange(items.Select(HireLinkRow.TryParse).OfType<HireLinkRow>());
}
catch (Exception e)
{
logger.LogError(
e, "Отчёт по вакансиям: приёмы из партиции №{Number} ({ListId}) не прочитаны", number, listId);
}
}
return result;
}
/// <summary>Все партиции типа — архив плюс текущая (она в реестр не пишется, а синтезируется).</summary>
private async Task<IReadOnlyList<(string ListId, int Number)>> PartitionsOfAsync(
RequestType type, CancellationToken cancellationToken)
{
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
return archived
.Select(x => (x.ListId, x.Number))
.Append((current.ListId, current.Number))
.ToList();
}
private static VacancyReportRow Build(
SelectionWithExecution selection,
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> hiresBySelection)
{
var (row, execution) = selection;
var hires = hiresBySelection.TryGetValue((row.Id, NormalizeListId(row.ListId)), out var found)
? found
: [];
// Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой
// кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы
// ложью («вот кого приняли»).
var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList();
return new VacancyReportRow(
row.Position ?? string.Empty,
row.DepartmentName,
row.VacanciesCount,
accepted.Count,
row.SubmitDate,
execution.ClosingDate,
ShortName(row.Initiator),
ClosedFlag(row.VacanciesCount, accepted.Count),
execution.Recruiter,
Lines(row, accepted));
}
/// <summary>
/// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет
/// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ
/// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней
/// заполняется из причины открытия вакансии.
/// </summary>
private static IReadOnlyList<VacancyReportLine> Lines(SelectionRow row, IReadOnlyList<HireLinkRow> accepted)
{
if (accepted.Count == 0)
return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))];
return accepted
// Порядок строк внутри вакансии — по дате выхода: так в отчёте видно,
// кто вышел раньше. Приём без даты уходит в конец.
.OrderBy(x => x.HireDate ?? DateOnly.MaxValue)
.ThenBy(x => x.CandidateName, StringComparer.CurrentCulture)
.Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x)))
.ToList();
}
/// <summary>
/// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле
/// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если
/// причина — замена уволенного: при «новой штатной единице» в том же поле
/// лежит совсем другой текст.
///
/// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии
/// он одинаковый — это нормально: причина открытия у вакансии одна.
///
/// hire = null — у вакансии приёмов нет вовсе, остаётся только откат.
/// </summary>
private static string? ReplacedEmployee(SelectionRow row, HireLinkRow? hire)
{
if (hire is not null && !string.IsNullOrWhiteSpace(hire.ReplacedEmployee))
return hire.ReplacedEmployee;
return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null;
}
private static string ClosedFlag(int vacancies, int closed) => closed switch
{
0 => VacancyClosedFlags.No,
_ when closed >= vacancies => VacancyClosedFlags.Yes,
_ => VacancyClosedFlags.Partial,
};
/// <summary>
/// «Фамилия Имя Отчество» → «Фамилия И.О.» — так инициатор записан в образце
/// отчёта («Ким Н.Ю.»). Имя из одного слова остаётся как есть.
/// </summary>
private static string? ShortName(string? fullName)
{
if (string.IsNullOrWhiteSpace(fullName)) return null;
var parts = fullName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2) return fullName.Trim();
var initials = string.Concat(parts.Skip(1).Select(x => $"{char.ToUpperInvariant(x[0])}."));
return $"{parts[0]} {initials}";
}
/// <summary>
/// Группировка по объекту/проекту — она же порядок строк на листе: сначала
/// вакансии без объекта (в образце это верхний блок без разделителя), затем
/// объекты по алфавиту, внутри группы — по дате подачи заявки.
/// </summary>
private static IReadOnlyList<VacancyReportGroup> Group(
IEnumerable<SelectionWithExecution> selections,
IReadOnlyDictionary<(int, string), IReadOnlyList<HireLinkRow>> hiresBySelection) => selections
.GroupBy(x => (x.Row.ObjectProject ?? string.Empty).Trim())
.OrderBy(g => g.Key.Length == 0 ? 0 : 1)
.ThenBy(g => g.Key, StringComparer.CurrentCulture)
.Select(g => new VacancyReportGroup(
g.Key,
g.OrderBy(x => x.Row.SubmitDate ?? DateOnly.MaxValue)
.ThenBy(x => x.Row.Id)
.Select(x => Build(x, hiresBySelection))
.ToList()))
.ToList();
/// <summary>
/// Фильтр периода по «Дате подачи заявки» с откатом на дату создания элемента:
/// SubmitDate — поле формы, заполняется руками и часто пустое, и без отката
/// такие заявки не попали бы в отчёт вообще.
///
/// Верхняя граница — по НАЧАЛУ следующего дня и строгим «lt»: даты в SharePoint
/// лежат с временем, и «le {to}» отсекло бы всё, что записано в сам день `to`
/// позже полуночи.
/// </summary>
private static string PeriodFilter(DateOnly from, DateOnly to)
{
var start = from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var end = to.AddDays(1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
return "$filter=" +
$"(SubmitDate ge datetime'{start}T00:00:00Z' and SubmitDate lt datetime'{end}T00:00:00Z') or " +
$"(SubmitDate eq null and Created ge datetime'{start}T00:00:00Z' " +
$"and Created lt datetime'{end}T00:00:00Z')";
}
/// <summary>
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
/// конвенции «&lt;Форма&gt; &lt;N&gt; — данные|согласование»: ListTitleConvention
/// в Infrastructure — internal, а здесь нужен только этот один переход.
/// </summary>
private static string? ApprovalListTitleFor(string? dataListTitle)
{
const string dataSuffix = " — данные";
if (string.IsNullOrWhiteSpace(dataListTitle) || !dataListTitle.EndsWith(dataSuffix, StringComparison.Ordinal))
return null;
return string.Concat(dataListTitle.AsSpan(0, dataListTitle.Length - dataSuffix.Length), " — согласование");
}
/// <summary>
/// Приведение server-relative url партиции к сравнимому виду: портал пишет
/// SelectionRequestListId не всегда в том же регистре и с тем же хвостовым слешем.
/// </summary>
private static string NormalizeListId(string? listId) =>
(listId ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
}
/// <summary>Заявка на подбор вместе с тем, что о ней знает её фаза исполнения.</summary>
internal sealed record SelectionWithExecution(SelectionRow Row, ExecutionInfo Execution);
/// <summary>Статусы списка «Прием на работу — данные», которые важны отчёту.</summary>
internal static class HireRequestStatus
{
public const string Rejected = "Отклонено";
}
/// <summary>Значение колонки «Причина открытия вакансии», при котором заполнено «вместо кого».</summary>
internal static class SelectionOpenReason
{
public const string Replacement = "Замена уволенного сотрудника";
}
/// <summary>
/// Что фаза исполнения заявки на подбор даёт отчёту. Рекрутёр — исполнитель
/// ПОСЛЕДНЕГО шага исполнения (шагов может быть несколько, вакансию довёл
/// последний); дата закрытия появляется только когда ВСЕ шаги исполнения
/// выполнены — пока хоть один в работе, вакансия не закрыта.
/// </summary>
internal sealed record ExecutionInfo(string? Recruiter, DateOnly? ClosingDate)
{
public static readonly ExecutionInfo Empty = new(null, null);
public static ExecutionInfo From(IEnumerable<ExecutionRow> rows)
{
var ordered = rows.OrderBy(x => x.StepOrder).ToList();
if (ordered.Count == 0) return Empty;
var recruiter = ordered
.Select(x => x.ExecutorName)
.LastOrDefault(x => !string.IsNullOrWhiteSpace(x));
var closingDate = ordered.All(x => x.Status == ExecutionRow.Done)
? ordered.Select(x => x.Date).Where(x => x is not null).Max()
: null;
return new ExecutionInfo(recruiter, closingDate);
}
}