Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs
T
gorbenko.pavel c0bc73559e docs: почему отчёт по вакансиям на items REST, а не на Search
Разбирались, можно ли перевести чтение на Search: один KQL сразу по всем
партициям, отставание индекса отчёту не критично. На тенанте проверено
поштучным сравнением Search и REST — часть нужных полей индекс не отдаёт
вообще, хотя значения в списках есть:
  • Заявка на подбор: OpenReason, OpenReasonDetails
  • Прием на работу: CandidateName, HireDate, ReplacedEmployee
  • — согласование: Ticket (Lookup — managed property не нашлась вовсе)
Префикс группы колонок сайта (TansuSite…) не помогает, задержка crawl тоже
ни при чём: у тех же свежих элементов рабочие поля Search отдаёт сразу.

Значит эти поля пришлось бы дочитывать построчно батчами по 100, и на 1000
заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST.
В list view threshold REST не упирается by design: партиции режутся на 4900
(ListPartitioning:ItemCountThreshold), ни один $filter не сканирует больше
предела.

Кода не меняем — фиксируем разбор в doc-комментарии, чтобы вопрос не
поднимался заново. Вернуться к Search осмысленно только после того, как в
Search Schema заведут работающие managed properties для этих полей.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:10:15 +05:00

380 lines
20 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):
/// • «Заявка на подбор»: OpenReason, OpenReasonDetails;
/// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee;
/// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под
/// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…).
/// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не
/// помогает — пробовали.
///
/// Значит эти поля пришлось бы дочитывать построчно батчами по 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 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);
}
}