4dac12302c
Второй лист xlsx по образцу ручного отчёта HR: горизонтальная таблица по рекрутёрам (всего/в работе %/закрыто %) и две вертикальные таблицы с долей каждого рекрутёра в общем объёме и в объёме "в работе" — плюс по круговой диаграмме на каждую (VacancyReportSummary, VacancyReportWorkbook.BuildSummarySheet). Библиотека для xlsx сменена с ClosedXML на NPOI: у ClosedXML нет поддержки Excel-диаграмм вовсе, только у NPOI из бесплатных вариантов она есть (XSSFChart/DataSources из NPOI.OOXML). Переписан весь генератор, тесты читают файл обратно тем же NPOI. Заодно запинена System.Security.Cryptography.Xml до 10.0.11 — версия, которую тянет NPOI транзитивно (8.0.2), с известной уязвимостью. Вакансии без рекрутёра сведены в «Без рекрутёра» и всегда идут в таблицах последними — не участник рейтинга, а технический хвост. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
71 lines
4.4 KiB
C#
71 lines
4.4 KiB
C#
namespace Tnsu.ApprovalRoute.Application.Reports;
|
|
|
|
/// <summary>
|
|
/// Разбивка отчёта по вакансиям по рекрутёрам — второй лист xlsx («Результаты» в
|
|
/// образце ручного отчёта HR): кто сколько вакансий ведёт, сколько из них в
|
|
/// работе/закрыто, и доля каждого рекрутёра в общем объёме — то, что дальше
|
|
/// рисуется как две круговые диаграммы.
|
|
///
|
|
/// Считается по той же колонке E (VacanciesCount), что и итоги на первом листе, а
|
|
/// не по числу заявок: одна заявка на подбор с несколькими вакансиями (напр.
|
|
/// «Разнорабочий ×4») засчитывается рекрутёру всеми своими местами разом — так же,
|
|
/// как единица подсчёта работает на первом листе.
|
|
/// </summary>
|
|
public sealed record VacancyReportSummary(int GrandTotal, int GrandInProgress, IReadOnlyList<RecruiterBreakdown> Recruiters)
|
|
{
|
|
/// <summary>Без рекрутёра не бывает: 0 вакансий = 0 рекрутёров, а не пустая диаграмма с NaN-процентами.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Строка для вакансий без рекрутёра (фаза исполнения ещё не началась или не даёт исполнителя).</summary>
|
|
public const string UnassignedLabel = "Без рекрутёра";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Один рекрутёр в сводке. Проценты не хранятся — считаются в момент вывода
|
|
/// (ShareOfTotal/ShareOfInProgress/InProgressRate ниже), чтобы не разъезжались с
|
|
/// Total/InProgress при округлении.
|
|
/// </summary>
|
|
public sealed record RecruiterBreakdown(string Name, int Total, int InProgress, int Closed)
|
|
{
|
|
/// <summary>Доля этого рекрутёра в общем объёме вакансий периода (для первой диаграммы).</summary>
|
|
public double ShareOfTotal(int grandTotal) => grandTotal > 0 ? (double)Total / grandTotal : 0;
|
|
|
|
/// <summary>Доля этого рекрутёра в объёме вакансий, которые ещё в работе (для второй диаграммы).</summary>
|
|
public double ShareOfInProgress(int grandInProgress) => grandInProgress > 0 ? (double)InProgress / grandInProgress : 0;
|
|
|
|
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые ещё в работе — «в работе (в%)» в горизонтальной таблице.</summary>
|
|
public double InProgressRate => Total > 0 ? (double)InProgress / Total : 0;
|
|
|
|
/// <summary>Доля вакансий ЭТОГО рекрутёра, которые уже закрыты — «закрыто» в горизонтальной таблице.</summary>
|
|
public double ClosedRate => Total > 0 ? (double)Closed / Total : 0;
|
|
}
|