namespace Tnsu.ApprovalRoute.Application.Reports; /// /// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в /// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в /// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше /// рисуется как две круговые диаграммы. /// /// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а /// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр. /// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же, /// как единица подсчёта работает на первом листе. /// public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList Recruiters) { /// Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами. public static readonly VacancyReportSummary Empty = new(0, 0, []); public static VacancyReportSummary Build(VacancyReport report) { var rows = report.Groups.SelectMany(g => g.Rows).ToList(); if (rows.Count == 0) return Empty; var recruiters = rows .GroupBy(r => string.IsNullOrWhiteSpace(r.Recruiter) ? UnassignedLabel : r.Recruiter!.Trim()) .Select(g => new { Name = g.Key, Total = g.Sum(r => r.VacanciesCount), Closed = g.Sum(r => r.ClosedCount), }) .ToList(); var grandTotal = recruiters.Sum(r => r.Total); var grandInProgress = recruiters.Sum(r => r.Total - r.Closed); // «Без рекрутёра» — всегда последним и независимо от объёма: это не // реальный участник рейтинга, чья позиция в сортировке имела бы смысл. var ordered = recruiters .OrderByDescending(r => r.Name != UnassignedLabel) .ThenByDescending(r => r.Total) .Select(r => new RecruiterBreakdown(r.Name, r.Total, r.Total - r.Closed, r.Closed)) .ToList(); return new VacancyReportSummary(grandTotal, grandInProgress, ordered); } /// Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя). public const string UnassignedLabel = "Без рекрутёра"; } /// /// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода /// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с /// Total/InProgress при округлении. /// public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed) { /// Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы). public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0; /// Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы). public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0; /// Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице. public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0; /// Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице. public double ClosedRate => Total > 0 ? (double)Closed / Total : 0; }