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;
}