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; /// /// Отчёт по вакансиям за период. Источник — две формы портала: «Заявка на подбор» /// даёт саму вакансию (должность, количество, объект, инициатор), «Приём на /// работу» — её закрытие (кого и когда приняли). Связь между ними — колонки /// SelectionRequestId / SelectionRequestListId заявки на приём. /// /// Рекрутёра и даты закрытия вакансии в самой заявке нет — они берутся из парного /// списка «— согласование», из строк фазы «Исполнение» (см. ExecutionRow). /// /// ПОЧЕМУ ЗДЕСЬ items REST ПО ПАРТИЦИЯМ, А НЕ SEARCH (разбирались 2026-08-28). /// Search выглядит уместнее — один KQL сразу по всем партициям, и отставание /// индекса отчёту не критично. Но на этом тенанте ЧАСТЬ нужных полей индекс не /// отдаёт вовсе, при том что в самих списках значения есть (проверено поштучно /// через REST): /// • «Заявка на подбор»: OpenReason, OpenReasonDetails; /// • «Прием на работу»: CandidateName, HireDate, ReplacedEmployee; /// • «— согласование»: Ticket — Lookup, managed property не нашлась ни под /// одним из разумных имён (TicketOWSTEXT/TicketOWSLKID/TicketId/…). /// Префикс группы колонок сайта (TansuSite…, см. tools/sp/README.md) тоже не /// помогает — пробовали. /// /// Причина, если понадобится чинить: одноимённые колонки в РАЗНЫХ списках — это /// разные поля со своими GUID, а значит и со своими crawled property /// (ows_Position, ows_Position1, …); автосозданное managed property привязано /// только к одному из них. Поэтому, например, PositionOWSTEXT отдаёт значения в /// «Заявка на подбор — данные» и молчит в «Прием на работу — данные». Проверять /// надо не «работает ли свойство», а «работает ли оно для ЭТОГО списка» — этим /// занимается `sp-write props` (репозиторий C:\work\git\tools), он же говорит, /// нужно ли домапить crawled property или свойства нет вовсе. /// /// Значит эти поля пришлось бы дочитывать построчно батчами по 100 — и на 1000 /// заявок Search+дочитка выходит ~46 HTTP-запросов против ~5 у нынешнего REST. /// В list view threshold (5000) REST при этом не упирается by design: партиции /// режутся на 4900 элементов (ListPartitioning:ItemCountThreshold), то есть ни /// один $filter не сканирует больше предела. /// /// Вернуться к Search имеет смысл, только если в Search Schema заведут /// работающие managed properties для перечисленных выше полей. /// public sealed class VacancyReportService( ApprovalRouteDbContext db, ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, IOptions options, ILogger logger) { public async Task> 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)[] : await ReadHiresAsync(hireType, cancellationToken); var hiresBySelection = hires .GroupBy(x => (x.SelectionRequestId, ListId: NormalizeListId(x.SelectionRequestListId))) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); return Result.Ok(new VacancyReport(from, to, Group(selections, hiresBySelection))); } private async Task FindTypeAsync(string pageUrl, CancellationToken cancellationToken) => await db.RequestTypes.AsNoTracking() .FirstOrDefaultAsync( x => x.PageUrl == pageUrl && x.ListId != null && x.SiteUrl != null, cancellationToken); /// /// Заявки на подбор за период по ВСЕМ партициям типа — вакансия, уехавшая в /// архивный список, из отчёта пропадать не должна: подбор по ней мог начаться /// задолго до того, как для новых заявок завели следующую партицию. /// private async Task> ReadSelectionsAsync( RequestType type, DateOnly from, DateOnly to, CancellationToken cancellationToken) { var result = new List(); 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> 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() .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(); } /// /// Строки фазы «Исполнение» партиции, свёрнутые по заявке. Заголовок списка /// согласования выводится из ЖИВОГО заголовка списка данных, а не из url: /// список могли переименовать руками. /// private async Task> 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() .GroupBy(x => x.TicketId) .ToDictionary(g => g.Key, ExecutionInfo.From); } private async Task> ReadHiresAsync( RequestType type, CancellationToken cancellationToken) { var result = new List(); 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()); } catch (Exception e) { logger.LogError( e, "Отчёт по вакансиям: приёмы из партиции №{Number} ({ListId}) не прочитаны", number, listId); } } return result; } /// Все партиции типа — архив плюс текущая (она в реестр не пишется, а синтезируется). private async Task> 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> 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)); } /// /// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет /// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ /// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней /// заполняется из причины открытия вакансии. /// private static IReadOnlyList Lines(SelectionRow row, IReadOnlyList 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(); } /// /// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле /// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если /// причина — замена уволенного: при «новой штатной единице» в том же поле /// лежит совсем другой текст. /// /// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии /// он одинаковый — это нормально: причина открытия у вакансии одна. /// /// hire = null — у вакансии приёмов нет вовсе, остаётся только откат. /// 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, }; /// /// «Фамилия Имя Отчество» → «Фамилия И.О.» — так инициатор записан в образце /// отчёта («Ким Н.Ю.»). Имя из одного слова остаётся как есть. /// 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}"; } /// /// Группировка по объекту/проекту — она же порядок строк на листе: сначала /// вакансии без объекта (в образце это верхний блок без разделителя), затем /// объекты по алфавиту, внутри группы — по дате подачи заявки. /// private static IReadOnlyList Group( IEnumerable selections, IReadOnlyDictionary<(int, string), IReadOnlyList> 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(); /// /// Фильтр периода по «Дате подачи заявки» с откатом на дату создания элемента: /// SubmitDate — поле формы, заполняется руками и часто пустое, и без отката /// такие заявки не попали бы в отчёт вообще. /// /// Верхняя граница — по НАЧАЛУ следующего дня и строгим «lt»: даты в SharePoint /// лежат с временем, и «le {to}» отсекло бы всё, что записано в сам день `to` /// позже полуночи. /// 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')"; } /// /// Имя парного списка согласования по имени списка данных. Ещё одна копия /// конвенции «<Форма> <N> — данные|согласование»: ListTitleConvention /// в Infrastructure — internal, а здесь нужен только этот один переход. /// 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), " — согласование"); } /// /// Приведение server-relative url партиции к сравнимому виду: портал пишет /// SelectionRequestListId не всегда в том же регистре и с тем же хвостовым слешем. /// private static string NormalizeListId(string? listId) => (listId ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant(); } /// Заявка на подбор вместе с тем, что о ней знает её фаза исполнения. internal sealed record SelectionWithExecution(SelectionRow Row, ExecutionInfo Execution); /// Статусы списка «Прием на работу — данные», которые важны отчёту. internal static class HireRequestStatus { public const string Rejected = "Отклонено"; } /// Значение колонки «Причина открытия вакансии», при котором заполнено «вместо кого». internal static class SelectionOpenReason { public const string Replacement = "Замена уволенного сотрудника"; } /// /// Что фаза исполнения заявки на подбор даёт отчёту. Рекрутёр — исполнитель /// ПОСЛЕДНЕГО шага исполнения (шагов может быть несколько, вакансию довёл /// последний); дата закрытия появляется только когда ВСЕ шаги исполнения /// выполнены — пока хоть один в работе, вакансия не закрыта. /// internal sealed record ExecutionInfo(string? Recruiter, DateOnly? ClosingDate) { public static readonly ExecutionInfo Empty = new(null, null); public static ExecutionInfo From(IEnumerable 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); } }