Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs
T
gorbenko.pavel 4dac12302c feat: сводный лист «Результаты» с диаграммами в отчёте по вакансиям
Второй лист 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>
2026-08-28 11:12:45 +05:00

482 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using NPOI.OOXML.XSSF.UserModel;
using NPOI.SS.UserModel;
using NPOI.SS.UserModel.Charts;
using NPOI.SS.Util;
using NPOI.XSSF.UserModel;
namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>
/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
/// круговыми диаграммами).
///
/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
///
/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
///
/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
/// руками, и после правки строки цифры должны пересчитаться сами.
/// </summary>
public static class VacancyReportWorkbook
{
private const string SheetName = "Список вакансий (все)";
private const string SummarySheetName = "Результаты";
private const string DateFormat = "dd.mm.yyyy";
private const string WholePercentFormat = "0%";
private const string PrecisePercentFormat = "0.00%";
/// <summary>Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).</summary>
private static readonly (string Title, double Width)[] Columns =
[
(" ", 6.1),
("№", 4.9),
("Вакансия", 57.4),
("Подразделение", 17.1),
("Кол-во вакансий", 8.3),
("количество закрытых вакансий", 9.1),
("количество вакансий в работе", 11.6),
("Дата подачи заявки", 14.7),
("Дата закрытия вакансии", 14.9),
("Срок отработки", 12.3),
("Дата выхода", 13.9),
("Инициатор заявки", 26.9),
("Вместо сотрудника Ф.И.О.", 26.0),
("Комментарии", 51.3),
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
("Рекрутер", 23.9),
];
// Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
private const int ColTotalNumber = 0;
private const int ColGroupNumber = 1;
private const int ColPosition = 2;
private const int ColDepartment = 3;
private const int ColVacancies = 4;
private const int ColClosed = 5;
private const int ColInProgress = 6;
private const int ColSubmitDate = 7;
private const int ColClosingDate = 8;
private const int ColLeadTime = 9;
private const int ColStartDate = 10;
private const int ColInitiator = 11;
private const int ColReplaced = 12;
private const int ColClosedFlag = 14;
private const int ColRecruiter = 15;
private const int HeaderRowIdx = 0;
public static byte[] Build(VacancyReport report)
{
using var workbook = new XSSFWorkbook();
var styles = new Styles(workbook);
BuildVacancyListSheet(workbook, styles, report);
BuildSummarySheet(workbook, styles, report);
using var stream = new MemoryStream();
workbook.Write(stream, leaveOpen: true);
return stream.ToArray();
}
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
public static string FileName(VacancyReport report) =>
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
// ==================== Лист 1: «Список вакансий (все)» ====================
private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
{
var sheet = (XSSFSheet)workbook.CreateSheet(SheetName);
WriteHeader(sheet, styles);
var rowIdx = HeaderRowIdx + 1;
var totalNumber = 0;
var firstDataRowIdx = rowIdx;
foreach (var group in report.Groups)
{
if (group.Name.Length > 0) WriteGroupSeparator(sheet, styles, rowIdx++, group.Name);
var groupNumber = 0;
foreach (var item in group.Rows)
WriteRow(sheet, styles, rowIdx++, ++totalNumber, ++groupNumber, item);
}
var lastDataRowIdx = rowIdx - 1;
// Итоги — только если есть что суммировать: SUM по пустому диапазону
// (firstDataRowIdx > lastDataRowIdx) дал бы формулу с перевёрнутым диапазоном.
if (lastDataRowIdx >= firstDataRowIdx) WriteTotals(sheet, styles, rowIdx, firstDataRowIdx, lastDataRowIdx);
FinishVacancyListSheet(sheet, styles, lastDataRowIdx);
}
private static void WriteHeader(XSSFSheet sheet, Styles styles)
{
var row = sheet.CreateRow(HeaderRowIdx);
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
// одну строку из трёх (высота 90 — из образца отчёта).
row.HeightInPoints = 90f;
for (var col = 0; col < Columns.Length; col++)
{
var (title, width) = Columns[col];
var cell = row.CreateCell(col);
cell.SetCellValue(title);
cell.CellStyle = styles.Header;
sheet.SetColumnWidth(col, (int)Math.Round(width * 256));
}
}
/// <summary>
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
/// начинается заново — так же, как в ручном отчёте.
/// </summary>
private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
{
var row = sheet.CreateRow(rowIdx);
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.GroupSeparator;
row.GetCell(ColGroupNumber).SetCellValue(name);
}
private static void WriteRow(
XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
{
var row = sheet.CreateRow(rowIdx);
var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки.
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Data;
row.GetCell(ColTotalNumber).SetCellValue(totalNumber);
row.GetCell(ColGroupNumber).SetCellValue(groupNumber);
row.GetCell(ColPosition).SetCellValue(item.Position);
row.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
row.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
row.GetCell(ColClosed).SetCellValue(item.ClosedCount);
row.GetCell(ColInProgress).SetCellFormula($"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate);
SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate);
// «Срок отработки» — разница дат в днях, и только когда известны обе:
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
// дней (пустая ячейка — это 00.01.1900).
if (item.SubmitDate is not null && item.ClosingDate is not null)
{
var leadTimeCell = row.GetCell(ColLeadTime);
leadTimeCell.CellStyle = styles.DataLeadTime;
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
}
SetDate(row.GetCell(ColStartDate), styles, item.StartDate);
row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty);
// Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в
// ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым
// в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
// в выгрузке.
row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
}
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
{
var row = sheet.CreateRow(rowIdx);
var firstExcelRow = firstDataRowIdx + 1;
var lastExcelRow = lastDataRowIdx + 1;
for (var col = 0; col < Columns.Length; col++)
row.CreateCell(col).CellStyle = styles.Totals;
foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
{
var letter = ColLetter(col);
row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
}
}
/// <summary>
/// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
/// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
/// вместе с отфильтрованными строками.
/// </summary>
private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
{
// Даты — дефолтным стилем колонки, а не только по заполненным ячейкам:
// формат должен остаться и у строк, которые допишут руками.
foreach (var col in (int[])[ColSubmitDate, ColClosingDate, ColStartDate])
sheet.SetDefaultColumnStyle(col, styles.DataDate);
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
// какая колонка какая.
sheet.CreateFreezePane(0, HeaderRowIdx + 1);
if (lastDataRowIdx > HeaderRowIdx)
sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
}
private static void SetDate(ICell cell, Styles styles, DateOnly? date)
{
if (date is null) return;
cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
cell.CellStyle = styles.DataDate;
}
// ==================== Лист 2: «Результаты» (сводка по рекрутёрам) ====================
/// <summary>
/// Сводка по рекрутёрам: горизонтальная таблица (кто сколько вакансий ведёт,
/// какая доля в работе/закрыта) и две вертикальные таблицы с долей каждого
/// рекрутёра в общем объёме — они же источник данных для двух круговых
/// диаграмм рядом. Раскладка — по образцу листа «Статус работы отдела_АХ» в
/// ручном отчёте HR.
/// </summary>
private static void BuildSummarySheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
{
var sheet = (XSSFSheet)workbook.CreateSheet(SummarySheetName);
sheet.SetColumnWidth(0, (int)Math.Round(26.0 * 256));
var titleRow = sheet.CreateRow(0);
titleRow.CreateCell(0).SetCellValue("результаты");
titleRow.GetCell(0).CellStyle = styles.Title;
var summary = VacancyReportSummary.Build(report);
if (summary.Recruiters.Count == 0)
{
sheet.CreateRow(2).CreateCell(0).SetCellValue("Нет данных за период.");
return;
}
var recruiters = summary.Recruiters;
for (var i = 0; i < recruiters.Count; i++)
sheet.SetColumnWidth(1 + i, (int)Math.Round(12.0 * 256));
WriteHorizontalTable(sheet, styles, summary);
const int horizontalTableRows = 4; // заголовок + «Всего» + «в работе» + «закрыто»
var vertTable1HeaderRowIdx = HeaderRowIdx + 1 + horizontalTableRows + 1; // +1 — пустая строка-отступ
var vertTable1DataStartRowIdx = vertTable1HeaderRowIdx + 1;
WriteVerticalTable(
sheet, styles, vertTable1HeaderRowIdx, "Общий объем работ", summary.GrandTotal,
recruiters, r => r.ShareOfTotal(summary.GrandTotal));
var vertTable2HeaderRowIdx = vertTable1DataStartRowIdx + recruiters.Count + 1;
var vertTable2DataStartRowIdx = vertTable2HeaderRowIdx + 1;
WriteVerticalTable(
sheet, styles, vertTable2HeaderRowIdx, "Общий объем в работе", summary.GrandInProgress,
recruiters, r => r.ShareOfInProgress(summary.GrandInProgress));
var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
CreatePieChart(
sheet, "Объём вакансий " + period,
firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable1HeaderRowIdx);
CreatePieChart(
sheet, "Объём вакансий в работе " + period,
firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: vertTable2HeaderRowIdx);
}
/// <summary>
/// «Кол-во вакансий» / «Всего» / «в работе (в%)» / «закрыто» — рекрутёры
/// колонками, как в образце отчёта (там же W:Y).
/// </summary>
private static void WriteHorizontalTable(XSSFSheet sheet, Styles styles, VacancyReportSummary summary)
{
var headerRow = sheet.CreateRow(HeaderRowIdx + 1);
var totalRow = sheet.CreateRow(HeaderRowIdx + 2);
var inProgressRow = sheet.CreateRow(HeaderRowIdx + 3);
var closedRow = sheet.CreateRow(HeaderRowIdx + 4);
SetCell(headerRow, 0, "Кол-во вакансий", styles.SummaryHeaderYellow);
SetCell(totalRow, 0, "Всего", styles.SummaryLabel);
SetCell(inProgressRow, 0, "в работе (в%)", styles.SummaryRowGreenLabel);
SetCell(closedRow, 0, "закрыто", styles.SummaryLabel);
for (var i = 0; i < summary.Recruiters.Count; i++)
{
var r = summary.Recruiters[i];
var col = 1 + i;
SetCell(headerRow, col, r.Name, styles.SummaryHeaderYellow);
SetCell(totalRow, col, r.Total, styles.SummaryValue);
SetCell(inProgressRow, col, r.InProgressRate, styles.SummaryRowGreenPercent);
SetCell(closedRow, col, r.ClosedRate, styles.SummaryPercent);
}
}
/// <summary>
/// Одна вертикальная таблица: шапка «label | итого» (синяя заливка, итого —
/// красным полужирным, как в образце) плюс по строке на рекрутёра с его
/// долей в этом итого. Возвращаемый диапазон строк — вход для круговой
/// диаграммы (см. CreatePieChart).
/// </summary>
private static void WriteVerticalTable(
XSSFSheet sheet, Styles styles, int headerRowIdx, string label, int total,
IReadOnlyList<RecruiterBreakdown> recruiters, Func<RecruiterBreakdown, double> share)
{
var headerRow = sheet.CreateRow(headerRowIdx);
SetCell(headerRow, 0, label, styles.SummaryBlueHeader);
SetCell(headerRow, 1, total, styles.SummaryBlueTotal);
for (var i = 0; i < recruiters.Count; i++)
{
var row = sheet.CreateRow(headerRowIdx + 1 + i);
SetCell(row, 0, recruiters[i].Name, styles.SummaryGreenText);
SetCell(row, 1, share(recruiters[i]), styles.SummaryGreenPercent);
}
}
/// <summary>
/// Круговая диаграмма по одной вертикальной таблице: категории — колонка A
/// (имена рекрутёров), значения — колонка B (доля). Высота якоря —
/// фиксированные 14 строк: чтения диаграммы ради нет смысла подгонять её
/// размер под число рекрутёров, малое число категорий на большом круге
/// читается ничуть не хуже.
/// </summary>
private static void CreatePieChart(
XSSFSheet sheet, string title, int firstDataRowIdx, int rowCount, int anchorTopRowIdx)
{
if (rowCount == 0) return;
var drawing = (XSSFDrawing)sheet.CreateDrawingPatriarch();
var anchor = drawing.CreateAnchor(0, 0, 0, 0, 3, anchorTopRowIdx, 11, anchorTopRowIdx + 14);
var chart = (XSSFChart)drawing.CreateChart(anchor);
chart.SetTitle(title);
var lastDataRowIdx = firstDataRowIdx + rowCount - 1;
var data = chart.ChartDataFactory.CreatePieChartData<string, double>();
var categories = DataSources.FromStringCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 0, 0));
var values = DataSources.FromNumericCellRange(sheet, new CellRangeAddress(firstDataRowIdx, lastDataRowIdx, 1, 1));
data.AddSeries(categories, values);
chart.Plot(data);
chart.GetOrCreateLegend();
}
private static void SetCell(IRow row, int col, string value, ICellStyle style)
{
var cell = row.CreateCell(col);
cell.SetCellValue(value);
cell.CellStyle = style;
}
private static void SetCell(IRow row, int col, double value, ICellStyle style)
{
var cell = row.CreateCell(col);
cell.SetCellValue(value);
cell.CellStyle = style;
}
private static string ColLetter(int col0Based) => CellReference.ConvertNumToColString(col0Based);
/// <summary>
/// Все стили обоих листов — созданы один раз на книгу и переиспользуются:
/// ICellStyle в NPOI (в отличие от ClosedXML) назначается ячейке целиком, а
/// не собирается по кусочкам, поэтому под каждую итоговую комбинацию
/// заливка+шрифт+формат нужен отдельный объект стиля.
/// </summary>
private sealed class Styles
{
public ICellStyle Header { get; }
public ICellStyle GroupSeparator { get; }
public ICellStyle Data { get; }
public ICellStyle DataDate { get; }
public ICellStyle DataLeadTime { get; }
public ICellStyle Totals { get; }
public ICellStyle Title { get; }
public ICellStyle SummaryHeaderYellow { get; }
public ICellStyle SummaryLabel { get; }
public ICellStyle SummaryValue { get; }
public ICellStyle SummaryPercent { get; }
public ICellStyle SummaryRowGreenLabel { get; }
public ICellStyle SummaryRowGreenPercent { get; }
public ICellStyle SummaryBlueHeader { get; }
public ICellStyle SummaryBlueTotal { get; }
public ICellStyle SummaryGreenText { get; }
public ICellStyle SummaryGreenPercent { get; }
// Цвета — из образца отчёта (заливка шапки FFE5E5FF и т.д.), чтобы
// выгрузка выглядела привычно рядом со старым файлом.
private static readonly byte[] HeaderFill = [0xE5, 0xE5, 0xFF];
private static readonly byte[] GroupFill = [0xF2, 0xF2, 0xF2];
private static readonly byte[] YellowFill = [0xFF, 0xFF, 0x00];
private static readonly byte[] GreenFill = [0xC6, 0xE0, 0xB4];
private static readonly byte[] BlueFill = [0x4A, 0x7E, 0xBB];
private static readonly byte[] RedFont = [0xC0, 0x00, 0x00];
private static readonly byte[] WhiteFont = [0xFF, 0xFF, 0xFF];
public Styles(XSSFWorkbook workbook)
{
var colorMap = new DefaultIndexedColorMap();
ICellStyle New(
byte[]? fill = null, bool bold = false, byte[]? fontColor = null, string? numberFormat = null,
bool wrap = false, bool border = false, bool topDoubleBorder = false,
HorizontalAlignment? align = null, VerticalAlignment? valign = null)
{
var style = (XSSFCellStyle)workbook.CreateCellStyle();
if (fill is not null)
{
style.SetFillForegroundColor(new XSSFColor(fill, colorMap));
style.FillPattern = FillPattern.SolidForeground;
}
if (bold || fontColor is not null)
{
var font = (XSSFFont)workbook.CreateFont();
font.IsBold = bold;
if (fontColor is not null) font.SetColor(new XSSFColor(fontColor, colorMap));
style.SetFont(font);
}
if (numberFormat is not null)
style.DataFormat = workbook.CreateDataFormat().GetFormat(numberFormat);
if (wrap) style.WrapText = true;
if (border)
{
style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin;
}
if (topDoubleBorder) style.BorderTop = BorderStyle.Double;
if (align is not null) style.Alignment = align.Value;
if (valign is not null) style.VerticalAlignment = valign.Value;
return style;
}
Header = New(
fill: HeaderFill, bold: true, wrap: true, border: true,
align: HorizontalAlignment.Center, valign: VerticalAlignment.Center);
GroupSeparator = New(fill: GroupFill, bold: true);
Data = New(wrap: true, border: true);
DataDate = New(wrap: true, border: true, numberFormat: DateFormat);
DataLeadTime = New(wrap: true, border: true, numberFormat: "0");
Totals = New(bold: true, topDoubleBorder: true);
Title = New(bold: true);
SummaryHeaderYellow = New(fill: YellowFill, bold: true);
SummaryLabel = New(bold: true);
SummaryValue = New();
SummaryPercent = New(numberFormat: WholePercentFormat);
SummaryRowGreenLabel = New(fill: GreenFill, bold: true);
SummaryRowGreenPercent = New(fill: GreenFill, numberFormat: WholePercentFormat);
SummaryBlueHeader = New(fill: BlueFill, bold: true, fontColor: WhiteFont);
SummaryBlueTotal = New(fill: BlueFill, bold: true, fontColor: RedFont);
SummaryGreenText = New(fill: GreenFill);
SummaryGreenPercent = New(fill: GreenFill, numberFormat: PrecisePercentFormat);
}
}
}