82c2dc36a6
В списке «Заявка на подбор — данные» колонки называются VacancyOpenReason и VacancyOpenReasonDetails; запрос с несуществующими OpenReason* падал, ошибка партиции глушилась в лог, и отчёт возвращался пустым. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/26
411 lines
22 KiB
C#
411 lines
22 KiB
C#
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>
|
||
/// Имя парного списка согласования по имени списка данных. Ещё одна копия
|
||
/// конвенции «<Форма> <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);
|
||
}
|
||
}
|