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>
This commit is contained in:
@@ -10,7 +10,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||||
<PackageVersion Include="ClosedXML" Version="0.105.1" />
|
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
@@ -35,8 +34,15 @@
|
|||||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
<!-- Только чтобы поднять транзитивную версию из Hangfire.Core — напрямую не используется. -->
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageVersion Include="NPOI" Version="2.7.6" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<!--
|
||||||
|
Только чтобы поднять транзитивную версию из NPOI (8.0.2 с известной
|
||||||
|
уязвимостью, см. GHSA-8q5v-6pqq-x66h и соседние advisories той же пачки)
|
||||||
|
— напрямую не используется. Убрать, когда NPOI сам подтянет исправленную.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.11" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,32 +1,39 @@
|
|||||||
using ClosedXML.Excel;
|
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;
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Отчёт по вакансиям в виде xlsx — лист «Список вакансий (все)» по образцу
|
/// Отчёт по вакансиям в виде xlsx — два листа по образцу ручного отчёта HR:
|
||||||
/// ручного отчёта HR: те же шестнадцать колонок A…P в том же порядке, строки
|
/// «Список вакансий (все)» (шестнадцать колонок A…P, строки-разделители по
|
||||||
/// разделители по объектам, итоги внизу.
|
/// объектам, итоги внизу) и «Результаты» (сводка по рекрутёрам с двумя
|
||||||
|
/// круговыми диаграммами).
|
||||||
///
|
///
|
||||||
/// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное,
|
/// Листы собираются кодом, а не заполнением файла-шаблона: число строк
|
||||||
/// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой
|
/// переменное (список вакансий) или зависит от состава рекрутёров (сводка) —
|
||||||
/// пришлось бы править на каждое изменение состава вакансий.
|
/// шаблон с такой структурой пришлось бы править на каждое изменение состава.
|
||||||
///
|
///
|
||||||
/// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся
|
/// Библиотека — NPOI, а не ClosedXML: только у NPOI из бесплатных вариантов
|
||||||
/// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после
|
/// есть настоящие Excel-диаграммы (см. WriteSummarySheet), без чего сводный
|
||||||
/// правки строки цифры должны пересчитаться сами.
|
/// лист выглядел бы просто как две таблицы без визуализации, как в образце.
|
||||||
|
///
|
||||||
|
/// Производные колонки (G «в работе», J «срок отработки») и итоги первого
|
||||||
|
/// листа пишутся ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше
|
||||||
|
/// руками, и после правки строки цифры должны пересчитаться сами.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class VacancyReportWorkbook
|
public static class VacancyReportWorkbook
|
||||||
{
|
{
|
||||||
private const string SheetName = "Список вакансий (все)";
|
private const string SheetName = "Список вакансий (все)";
|
||||||
|
private const string SummarySheetName = "Результаты";
|
||||||
// Цвет шапки — из образца отчёта (fill FFE5E5FF), чтобы выгрузка выглядела
|
|
||||||
// привычно рядом со старым файлом.
|
|
||||||
private static readonly XLColor HeaderFill = XLColor.FromArgb(0xE5, 0xE5, 0xFF);
|
|
||||||
private static readonly XLColor GroupFill = XLColor.FromArgb(0xF2, 0xF2, 0xF2);
|
|
||||||
|
|
||||||
private const string DateFormat = "dd.mm.yyyy";
|
private const string DateFormat = "dd.mm.yyyy";
|
||||||
|
private const string WholePercentFormat = "0%";
|
||||||
|
private const string PrecisePercentFormat = "0.00%";
|
||||||
|
|
||||||
/// <summary>Колонка листа: заголовок и ширина (ширины — из образца отчёта).</summary>
|
/// <summary>Колонка первого листа: заголовок и ширина (ширины — из образца отчёта).</summary>
|
||||||
private static readonly (string Title, double Width)[] Columns =
|
private static readonly (string Title, double Width)[] Columns =
|
||||||
[
|
[
|
||||||
(" ", 6.1),
|
(" ", 6.1),
|
||||||
@@ -47,56 +54,35 @@ public static class VacancyReportWorkbook
|
|||||||
("Рекрутер", 23.9),
|
("Рекрутер", 23.9),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Номера колонок — именами, чтобы формулы и стили не разъезжались с Columns.
|
// Индексы колонок — 0-based, как того требует NPOI (Row.CreateCell и т.п.).
|
||||||
private const int ColTotalNumber = 1;
|
private const int ColTotalNumber = 0;
|
||||||
private const int ColGroupNumber = 2;
|
private const int ColGroupNumber = 1;
|
||||||
private const int ColPosition = 3;
|
private const int ColPosition = 2;
|
||||||
private const int ColDepartment = 4;
|
private const int ColDepartment = 3;
|
||||||
private const int ColVacancies = 5;
|
private const int ColVacancies = 4;
|
||||||
private const int ColClosed = 6;
|
private const int ColClosed = 5;
|
||||||
private const int ColInProgress = 7;
|
private const int ColInProgress = 6;
|
||||||
private const int ColSubmitDate = 8;
|
private const int ColSubmitDate = 7;
|
||||||
private const int ColClosingDate = 9;
|
private const int ColClosingDate = 8;
|
||||||
private const int ColLeadTime = 10;
|
private const int ColLeadTime = 9;
|
||||||
private const int ColStartDate = 11;
|
private const int ColStartDate = 10;
|
||||||
private const int ColInitiator = 12;
|
private const int ColInitiator = 11;
|
||||||
private const int ColReplaced = 13;
|
private const int ColReplaced = 12;
|
||||||
private const int ColComment = 14;
|
private const int ColClosedFlag = 14;
|
||||||
private const int ColClosedFlag = 15;
|
private const int ColRecruiter = 15;
|
||||||
private const int ColRecruiter = 16;
|
|
||||||
|
|
||||||
private const int HeaderRow = 1;
|
private const int HeaderRowIdx = 0;
|
||||||
|
|
||||||
public static byte[] Build(VacancyReport report)
|
public static byte[] Build(VacancyReport report)
|
||||||
{
|
{
|
||||||
using var workbook = new XLWorkbook();
|
using var workbook = new XSSFWorkbook();
|
||||||
var sheet = workbook.AddWorksheet(SheetName);
|
var styles = new Styles(workbook);
|
||||||
|
|
||||||
WriteHeader(sheet);
|
BuildVacancyListSheet(workbook, styles, report);
|
||||||
|
BuildSummarySheet(workbook, styles, report);
|
||||||
var row = HeaderRow + 1;
|
|
||||||
var totalNumber = 0;
|
|
||||||
var firstDataRow = row;
|
|
||||||
|
|
||||||
foreach (var group in report.Groups)
|
|
||||||
{
|
|
||||||
if (group.Name.Length > 0) WriteGroupSeparator(sheet, row++, group.Name);
|
|
||||||
|
|
||||||
var groupNumber = 0;
|
|
||||||
foreach (var item in group.Rows)
|
|
||||||
WriteRow(sheet, row++, ++totalNumber, ++groupNumber, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastDataRow = row - 1;
|
|
||||||
|
|
||||||
// Итоги — только если есть что суммировать: SUM по пустому диапазону
|
|
||||||
// (firstDataRow > lastDataRow) дал бы формулу с перевёрнутым диапазоном.
|
|
||||||
if (lastDataRow >= firstDataRow) WriteTotals(sheet, row, firstDataRow, lastDataRow);
|
|
||||||
|
|
||||||
Finish(sheet, lastDataRow);
|
|
||||||
|
|
||||||
using var stream = new MemoryStream();
|
using var stream = new MemoryStream();
|
||||||
workbook.SaveAs(stream);
|
workbook.Write(stream, leaveOpen: true);
|
||||||
return stream.ToArray();
|
return stream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,29 +90,51 @@ public static class VacancyReportWorkbook
|
|||||||
public static string FileName(VacancyReport report) =>
|
public static string FileName(VacancyReport report) =>
|
||||||
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
|
$"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx";
|
||||||
|
|
||||||
private static void WriteHeader(IXLWorksheet sheet)
|
// ==================== Лист 1: «Список вакансий (все)» ====================
|
||||||
{
|
|
||||||
for (var i = 0; i < Columns.Length; i++)
|
|
||||||
{
|
|
||||||
var (title, width) = Columns[i];
|
|
||||||
var column = i + 1;
|
|
||||||
|
|
||||||
sheet.Cell(HeaderRow, column).Value = title;
|
private static void BuildVacancyListSheet(XSSFWorkbook workbook, Styles styles, VacancyReport report)
|
||||||
sheet.Column(column).Width = width;
|
{
|
||||||
|
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 header = sheet.Range(HeaderRow, 1, HeaderRow, Columns.Length);
|
var lastDataRowIdx = rowIdx - 1;
|
||||||
header.Style.Fill.BackgroundColor = HeaderFill;
|
|
||||||
header.Style.Font.Bold = true;
|
|
||||||
header.Style.Alignment.WrapText = true;
|
|
||||||
header.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
|
||||||
header.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
|
||||||
header.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
|
|
||||||
header.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
|
||||||
|
|
||||||
|
// Итоги — только если есть что суммировать: 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 показал бы
|
// Заголовки длинные и переносятся — без заданной высоты Excel показал бы
|
||||||
// одну строку из трёх (высота 90 — из образца отчёта).
|
// одну строку из трёх (высота 90 — из образца отчёта).
|
||||||
sheet.Row(HeaderRow).Height = 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>
|
/// <summary>
|
||||||
@@ -134,91 +142,340 @@ public static class VacancyReportWorkbook
|
|||||||
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
|
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
|
||||||
/// начинается заново — так же, как в ручном отчёте.
|
/// начинается заново — так же, как в ручном отчёте.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void WriteGroupSeparator(IXLWorksheet sheet, int row, string name)
|
private static void WriteGroupSeparator(XSSFSheet sheet, Styles styles, int rowIdx, string name)
|
||||||
{
|
{
|
||||||
sheet.Cell(row, ColGroupNumber).Value = name;
|
var row = sheet.CreateRow(rowIdx);
|
||||||
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
|
row.CreateCell(col).CellStyle = styles.GroupSeparator;
|
||||||
|
|
||||||
var range = sheet.Range(row, 1, row, Columns.Length);
|
row.GetCell(ColGroupNumber).SetCellValue(name);
|
||||||
range.Style.Fill.BackgroundColor = GroupFill;
|
|
||||||
range.Style.Font.Bold = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteRow(IXLWorksheet sheet, int row, int totalNumber, int groupNumber, VacancyReportRow item)
|
private static void WriteRow(
|
||||||
|
XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
|
||||||
{
|
{
|
||||||
sheet.Cell(row, ColTotalNumber).Value = totalNumber;
|
var row = sheet.CreateRow(rowIdx);
|
||||||
sheet.Cell(row, ColGroupNumber).Value = groupNumber;
|
var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки.
|
||||||
sheet.Cell(row, ColPosition).Value = item.Position;
|
|
||||||
sheet.Cell(row, ColDepartment).Value = item.Department ?? string.Empty;
|
|
||||||
sheet.Cell(row, ColVacancies).Value = item.VacanciesCount;
|
|
||||||
sheet.Cell(row, ColClosed).Value = item.ClosedCount;
|
|
||||||
sheet.Cell(row, ColInProgress).FormulaA1 = $"={Letter(ColVacancies)}{row}-{Letter(ColClosed)}{row}";
|
|
||||||
|
|
||||||
SetDate(sheet.Cell(row, ColSubmitDate), item.SubmitDate);
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
SetDate(sheet.Cell(row, ColClosingDate), item.ClosingDate);
|
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 тысяч
|
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
|
||||||
// дней (пустая ячейка — это 00.01.1900).
|
// дней (пустая ячейка — это 00.01.1900).
|
||||||
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
||||||
{
|
{
|
||||||
sheet.Cell(row, ColLeadTime).FormulaA1 =
|
var leadTimeCell = row.GetCell(ColLeadTime);
|
||||||
$"={Letter(ColClosingDate)}{row}-{Letter(ColSubmitDate)}{row}";
|
leadTimeCell.CellStyle = styles.DataLeadTime;
|
||||||
sheet.Cell(row, ColLeadTime).Style.NumberFormat.Format = "0";
|
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
||||||
}
|
}
|
||||||
|
|
||||||
SetDate(sheet.Cell(row, ColStartDate), item.StartDate);
|
SetDate(row.GetCell(ColStartDate), styles, item.StartDate);
|
||||||
|
|
||||||
sheet.Cell(row, ColInitiator).Value = item.Initiator ?? string.Empty;
|
row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
||||||
sheet.Cell(row, ColReplaced).Value = item.ReplacedEmployee ?? string.Empty;
|
row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty);
|
||||||
// Колонка «Комментарии» остаётся пустой намеренно: в ручном отчёте это
|
// Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в
|
||||||
// пометки рекрутёра («в подборе», «на стопе»), которым в заявках сейчас
|
// ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым
|
||||||
// негде храниться. Колонку оставляем, чтобы её дописывали в выгрузке.
|
// в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
||||||
sheet.Cell(row, ColClosedFlag).Value = item.ClosedFlag;
|
// в выгрузке.
|
||||||
sheet.Cell(row, ColRecruiter).Value = item.Recruiter ?? string.Empty;
|
row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
||||||
|
row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
|
||||||
sheet.Range(row, 1, row, Columns.Length).Style.Alignment.WrapText = true;
|
|
||||||
sheet.Range(row, 1, row, Columns.Length).Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
|
|
||||||
sheet.Range(row, 1, row, Columns.Length).Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriteTotals(IXLWorksheet sheet, int row, int firstDataRow, int lastDataRow)
|
private static void WriteTotals(XSSFSheet sheet, Styles styles, int rowIdx, int firstDataRowIdx, int lastDataRowIdx)
|
||||||
{
|
{
|
||||||
foreach (var column in (int[])[ColVacancies, ColClosed, ColInProgress])
|
var row = sheet.CreateRow(rowIdx);
|
||||||
sheet.Cell(row, column).FormulaA1 =
|
var firstExcelRow = firstDataRowIdx + 1;
|
||||||
$"=SUM({Letter(column)}{firstDataRow}:{Letter(column)}{lastDataRow})";
|
var lastExcelRow = lastDataRowIdx + 1;
|
||||||
|
|
||||||
var range = sheet.Range(row, 1, row, Columns.Length);
|
for (var col = 0; col < Columns.Length; col++)
|
||||||
range.Style.Font.Bold = true;
|
row.CreateCell(col).CellStyle = styles.Totals;
|
||||||
range.Style.Border.TopBorder = XLBorderStyleValues.Double;
|
|
||||||
|
foreach (var col in (int[])[ColVacancies, ColClosed, ColInProgress])
|
||||||
|
{
|
||||||
|
var letter = ColLetter(col);
|
||||||
|
row.GetCell(col).SetCellFormula($"SUM({letter}{firstExcelRow}:{letter}{lastExcelRow})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её
|
/// lastDataRowIdx — последняя строка ДАННЫХ, без строки итогов: автофильтр
|
||||||
/// захватывать не должен, иначе при любом фильтре итоги уезжают из вида
|
/// её захватывать не должен, иначе при любом фильтре итоги уезжают из вида
|
||||||
/// вместе с отфильтрованными строками.
|
/// вместе с отфильтрованными строками.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void Finish(IXLWorksheet sheet, int lastDataRow)
|
private static void FinishVacancyListSheet(XSSFSheet sheet, Styles styles, int lastDataRowIdx)
|
||||||
{
|
{
|
||||||
// Даты — по всей колонке, а не по заполненным ячейкам: формат должен
|
// Даты — дефолтным стилем колонки, а не только по заполненным ячейкам:
|
||||||
// остаться и у строк, которые допишут руками.
|
// формат должен остаться и у строк, которые допишут руками.
|
||||||
foreach (var column in (int[])[ColSubmitDate, ColClosingDate, ColStartDate])
|
foreach (var col in (int[])[ColSubmitDate, ColClosingDate, ColStartDate])
|
||||||
sheet.Column(column).Style.NumberFormat.Format = DateFormat;
|
sheet.SetDefaultColumnStyle(col, styles.DataDate);
|
||||||
|
|
||||||
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
|
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
|
||||||
// какая колонка какая.
|
// какая колонка какая.
|
||||||
sheet.SheetView.FreezeRows(HeaderRow);
|
sheet.CreateFreezePane(0, HeaderRowIdx + 1);
|
||||||
|
|
||||||
if (lastDataRow > HeaderRow)
|
if (lastDataRowIdx > HeaderRowIdx)
|
||||||
sheet.Range(HeaderRow, 1, lastDataRow, Columns.Length).SetAutoFilter();
|
sheet.SetAutoFilter(new CellRangeAddress(HeaderRowIdx, lastDataRowIdx, 0, Columns.Length - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetDate(IXLCell cell, DateOnly? date)
|
private static void SetDate(ICell cell, Styles styles, DateOnly? date)
|
||||||
{
|
{
|
||||||
if (date is null) return;
|
if (date is null) return;
|
||||||
|
|
||||||
cell.Value = date.Value.ToDateTime(TimeOnly.MinValue);
|
cell.SetCellValue(date.Value.ToDateTime(TimeOnly.MinValue));
|
||||||
cell.Style.NumberFormat.Format = DateFormat;
|
cell.CellStyle = styles.DataDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Letter(int column) => XLHelper.GetColumnLetterFromNumber(column);
|
// ==================== Лист 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter. -->
|
<!-- Генерация xlsx для отчётов (Reports/) — стили, формулы, autofilter, диаграммы. -->
|
||||||
<PackageReference Include="ClosedXML" />
|
<PackageReference Include="NPOI" />
|
||||||
<PackageReference Include="FluentResults" />
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="FluentValidation" />
|
<PackageReference Include="FluentValidation" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
|||||||
+194
-58
@@ -1,14 +1,16 @@
|
|||||||
using ClosedXML.Excel;
|
using NPOI.SS.UserModel;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Tnsu.ApprovalRoute.Application.Reports;
|
using Tnsu.ApprovalRoute.Application.Reports;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Раскладка листа «Список вакансий (все)»: порядок колонок, строки-разделители
|
/// Раскладка обоих листов книги: на «Список вакансий (все)» — порядок колонок,
|
||||||
/// по объектам, двойная нумерация (сквозная + внутри объекта) и формулы. Файл
|
/// строки-разделители по объектам, двойная нумерация (сквозная + внутри
|
||||||
/// собирается в памяти и тут же читается обратно тем же ClosedXML — проверяется
|
/// объекта) и формулы; на «Результаты» — сводка по рекрутёрам и то, что под неё
|
||||||
/// именно то, что увидит Excel.
|
/// заведены круговые диаграммы. Файл собирается в памяти и тут же читается
|
||||||
|
/// обратно тем же NPOI — проверяется именно то, что увидит Excel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class VacancyReportWorkbookTests
|
public sealed class VacancyReportWorkbookTests
|
||||||
{
|
{
|
||||||
@@ -33,122 +35,147 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes,
|
||||||
recruiter);
|
recruiter);
|
||||||
|
|
||||||
private static IXLWorksheet Build(params VacancyReportGroup[] groups)
|
private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups)
|
||||||
{
|
{
|
||||||
var report = new VacancyReport(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups);
|
var report = new VacancyReport(from, to, groups);
|
||||||
var bytes = VacancyReportWorkbook.Build(report);
|
var bytes = VacancyReportWorkbook.Build(report);
|
||||||
|
return new XSSFWorkbook(new MemoryStream(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
using var stream = new MemoryStream(bytes);
|
private static ISheet BuildListSheet(params VacancyReportGroup[] groups) =>
|
||||||
// Книга закрывается вместе с using, но лист остаётся читаемым: ClosedXML
|
BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31), groups)
|
||||||
// держит уже разобранную модель в памяти, а не ленивую ссылку на поток.
|
.GetSheet("Список вакансий (все)");
|
||||||
var workbook = new XLWorkbook(stream);
|
|
||||||
return workbook.Worksheet("Список вакансий (все)");
|
/// <summary>Ячейка по Excel-координатам (1-based), как их читает человек — «строка 2, колонка E».</summary>
|
||||||
|
private static ICell? CellAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
sheet.GetRow(excelRow - 1)?.GetCell(excelCol - 1);
|
||||||
|
|
||||||
|
private static string StringAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
CellAt(sheet, excelRow, excelCol)?.StringCellValue ?? "";
|
||||||
|
|
||||||
|
private static int IntAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
(int)(CellAt(sheet, excelRow, excelCol)?.NumericCellValue ?? throw new InvalidOperationException("Ячейка пуста"));
|
||||||
|
|
||||||
|
private static DateTime DateAt(ISheet sheet, int excelRow, int excelCol) =>
|
||||||
|
CellAt(sheet, excelRow, excelCol)!.DateCellValue!.Value;
|
||||||
|
|
||||||
|
private static bool IsEmptyAt(ISheet sheet, int excelRow, int excelCol)
|
||||||
|
{
|
||||||
|
var cell = CellAt(sheet, excelRow, excelCol);
|
||||||
|
return cell is null || cell.CellType == CellType.Blank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Текст формулы БЕЗ ведущего «=» — как её хранит NPOI (Blank-ячейка формулы не имеет вовсе).</summary>
|
||||||
|
private static string FormulaAt(ISheet sheet, int excelRow, int excelCol)
|
||||||
|
{
|
||||||
|
var cell = CellAt(sheet, excelRow, excelCol);
|
||||||
|
return cell?.CellType == CellType.Formula ? cell.CellFormula : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")]
|
[Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")]
|
||||||
public void Header_MatchesSample()
|
public void Header_MatchesSample()
|
||||||
{
|
{
|
||||||
var sheet = Build(new VacancyReportGroup("", [Row("Логист")]));
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
sheet.Cell(1, 2).GetString().ShouldBe("№");
|
StringAt(sheet, 1, 2).ShouldBe("№");
|
||||||
sheet.Cell(1, 3).GetString().ShouldBe("Вакансия");
|
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||||||
sheet.Cell(1, 4).GetString().ShouldBe("Подразделение");
|
StringAt(sheet, 1, 4).ShouldBe("Подразделение");
|
||||||
sheet.Cell(1, 5).GetString().ShouldBe("Кол-во вакансий");
|
StringAt(sheet, 1, 5).ShouldBe("Кол-во вакансий");
|
||||||
sheet.Cell(1, 8).GetString().ShouldBe("Дата подачи заявки");
|
StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки");
|
||||||
sheet.Cell(1, 9).GetString().ShouldBe("Дата закрытия вакансии");
|
StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии");
|
||||||
sheet.Cell(1, 11).GetString().ShouldBe("Дата выхода");
|
StringAt(sheet, 1, 11).ShouldBe("Дата выхода");
|
||||||
sheet.Cell(1, 12).GetString().ShouldBe("Инициатор заявки");
|
StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки");
|
||||||
sheet.Cell(1, 14).GetString().ShouldBe("Комментарии");
|
StringAt(sheet, 1, 14).ShouldBe("Комментарии");
|
||||||
sheet.Cell(1, 16).GetString().ShouldBe("Рекрутер");
|
StringAt(sheet, 1, 16).ShouldBe("Рекрутер");
|
||||||
sheet.Cell(1, 17).IsEmpty().ShouldBeTrue();
|
IsEmptyAt(sheet, 1, 17).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
|
[Fact(DisplayName = "Строка вакансии: значения в своих колонках, «Комментарии» пусты")]
|
||||||
public void DataRow_Layout()
|
public void DataRow_Layout()
|
||||||
{
|
{
|
||||||
var sheet = Build(new VacancyReportGroup("", [
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
|
Row("Зам нач.юр.отдела", vacancies: 1, closed: 1,
|
||||||
submitDate: new DateOnly(2026, 3, 30),
|
submitDate: new DateOnly(2026, 3, 30),
|
||||||
closingDate: new DateOnly(2026, 7, 8),
|
closingDate: new DateOnly(2026, 7, 8),
|
||||||
startDate: new DateOnly(2026, 8, 17))
|
startDate: new DateOnly(2026, 8, 17))
|
||||||
]));
|
]));
|
||||||
|
|
||||||
sheet.Cell(2, 1).GetValue<int>().ShouldBe(1);
|
IntAt(sheet, 2, 1).ShouldBe(1);
|
||||||
sheet.Cell(2, 3).GetString().ShouldBe("Зам нач.юр.отдела");
|
StringAt(sheet, 2, 3).ShouldBe("Зам нач.юр.отдела");
|
||||||
sheet.Cell(2, 5).GetValue<int>().ShouldBe(1);
|
IntAt(sheet, 2, 5).ShouldBe(1);
|
||||||
sheet.Cell(2, 6).GetValue<int>().ShouldBe(1);
|
IntAt(sheet, 2, 6).ShouldBe(1);
|
||||||
sheet.Cell(2, 8).GetDateTime().ShouldBe(new DateTime(2026, 3, 30));
|
DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30));
|
||||||
sheet.Cell(2, 9).GetDateTime().ShouldBe(new DateTime(2026, 7, 8));
|
DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8));
|
||||||
sheet.Cell(2, 11).GetDateTime().ShouldBe(new DateTime(2026, 8, 17));
|
DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17));
|
||||||
sheet.Cell(2, 12).GetString().ShouldBe("Ким Н.Ю.");
|
StringAt(sheet, 2, 12).ShouldBe("Ким Н.Ю.");
|
||||||
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
// «Комментарии» намеренно пусты — в заявках этих пометок пока нет.
|
||||||
sheet.Cell(2, 14).IsEmpty().ShouldBeTrue();
|
IsEmptyAt(sheet, 2, 14).ShouldBeTrue();
|
||||||
sheet.Cell(2, 15).GetString().ShouldBe("да");
|
StringAt(sheet, 2, 15).ShouldBe("да");
|
||||||
sheet.Cell(2, 16).GetString().ShouldBe("Сергеева Надежда Ивановна");
|
StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
[Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]
|
||||||
public void DerivedColumns_AreFormulas()
|
public void DerivedColumns_AreFormulas()
|
||||||
{
|
{
|
||||||
var sheet = Build(new VacancyReportGroup("", [
|
var sheet = BuildListSheet(new VacancyReportGroup("", [
|
||||||
Row("Логист", vacancies: 3, closed: 1,
|
Row("Логист", vacancies: 3, closed: 1,
|
||||||
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
|
submitDate: new DateOnly(2026, 7, 1), closingDate: new DateOnly(2026, 7, 15))
|
||||||
]));
|
]));
|
||||||
|
|
||||||
sheet.Cell(2, 7).FormulaA1.ShouldBe("E2-F2");
|
FormulaAt(sheet, 2, 7).ShouldBe("E2-F2");
|
||||||
sheet.Cell(2, 10).FormulaA1.ShouldBe("I2-H2");
|
FormulaAt(sheet, 2, 10).ShouldBe("I2-H2");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
|
[Fact(DisplayName = "Без даты закрытия «срок отработки» пуст — иначе Excel вычел бы из 00.01.1900")]
|
||||||
public void LeadTime_EmptyWithoutClosingDate()
|
public void LeadTime_EmptyWithoutClosingDate()
|
||||||
{
|
{
|
||||||
var sheet = Build(new VacancyReportGroup("", [Row("Логист")]));
|
var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")]));
|
||||||
|
|
||||||
sheet.Cell(2, 10).FormulaA1.ShouldBeEmpty();
|
FormulaAt(sheet, 2, 10).ShouldBeEmpty();
|
||||||
sheet.Cell(2, 10).IsEmpty().ShouldBeTrue();
|
IsEmptyAt(sheet, 2, 10).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
|
[Fact(DisplayName = "Разделитель объекта: имя в колонке «№», нумерация внутри объекта — заново")]
|
||||||
public void GroupSeparator_RestartsNumbering()
|
public void GroupSeparator_RestartsNumbering()
|
||||||
{
|
{
|
||||||
var sheet = Build(
|
var sheet = BuildListSheet(
|
||||||
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
|
new VacancyReportGroup("", [Row("Логист"), Row("Юрист")]),
|
||||||
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
|
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка"), Row("Водитель самосвала")]));
|
||||||
|
|
||||||
// Верхний блок без объекта — сразу после шапки, без разделителя.
|
// Верхний блок без объекта — сразу после шапки, без разделителя.
|
||||||
sheet.Cell(2, 3).GetString().ShouldBe("Логист");
|
StringAt(sheet, 2, 3).ShouldBe("Логист");
|
||||||
sheet.Cell(3, 3).GetString().ShouldBe("Юрист");
|
StringAt(sheet, 3, 3).ShouldBe("Юрист");
|
||||||
|
|
||||||
sheet.Cell(4, 2).GetString().ShouldBe("КЭС КУРЧАТОВ");
|
StringAt(sheet, 4, 2).ShouldBe("КЭС КУРЧАТОВ");
|
||||||
sheet.Cell(4, 3).IsEmpty().ShouldBeTrue();
|
IsEmptyAt(sheet, 4, 3).ShouldBeTrue();
|
||||||
|
|
||||||
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
|
// Сквозная нумерация продолжается, нумерация внутри объекта — с единицы.
|
||||||
sheet.Cell(5, 1).GetValue<int>().ShouldBe(3);
|
IntAt(sheet, 5, 1).ShouldBe(3);
|
||||||
sheet.Cell(5, 2).GetValue<int>().ShouldBe(1);
|
IntAt(sheet, 5, 2).ShouldBe(1);
|
||||||
sheet.Cell(6, 1).GetValue<int>().ShouldBe(4);
|
IntAt(sheet, 6, 1).ShouldBe(4);
|
||||||
sheet.Cell(6, 2).GetValue<int>().ShouldBe(2);
|
IntAt(sheet, 6, 2).ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
|
[Fact(DisplayName = "Итоги — SUM по всем строкам данных, включая строки-разделители в диапазоне")]
|
||||||
public void Totals_SumWholeDataRange()
|
public void Totals_SumWholeDataRange()
|
||||||
{
|
{
|
||||||
var sheet = Build(
|
var sheet = BuildListSheet(
|
||||||
new VacancyReportGroup("", [Row("Логист")]),
|
new VacancyReportGroup("", [Row("Логист")]),
|
||||||
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
|
new VacancyReportGroup("КЭС КУРЧАТОВ", [Row("Оператор катка")]));
|
||||||
|
|
||||||
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
|
// Шапка 1, данные 2, разделитель 3, данные 4 → итоги в 5-й строке.
|
||||||
sheet.Cell(5, 5).FormulaA1.ShouldBe("SUM(E2:E4)");
|
FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)");
|
||||||
sheet.Cell(5, 6).FormulaA1.ShouldBe("SUM(F2:F4)");
|
FormulaAt(sheet, 5, 6).ShouldBe("SUM(F2:F4)");
|
||||||
sheet.Cell(5, 7).FormulaA1.ShouldBe("SUM(G2:G4)");
|
FormulaAt(sheet, 5, 7).ShouldBe("SUM(G2:G4)");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
|
[Fact(DisplayName = "Пустой отчёт — файл с одной шапкой, без строки итогов")]
|
||||||
public void EmptyReport_HeaderOnly()
|
public void EmptyReport_HeaderOnly()
|
||||||
{
|
{
|
||||||
var sheet = Build();
|
var sheet = BuildListSheet();
|
||||||
|
|
||||||
sheet.Cell(1, 3).GetString().ShouldBe("Вакансия");
|
StringAt(sheet, 1, 3).ShouldBe("Вакансия");
|
||||||
sheet.Cell(2, 5).IsEmpty().ShouldBeTrue();
|
IsEmptyAt(sheet, 2, 5).ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
|
[Fact(DisplayName = "Имя файла — с периодом отчёта")]
|
||||||
@@ -158,4 +185,113 @@ public sealed class VacancyReportWorkbookTests
|
|||||||
|
|
||||||
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
|
VacancyReportWorkbook.FileName(report).ShouldBe("Отчет по вакансиям 01.07.2026-31.08.2026.xlsx");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Лист «Результаты» ====================
|
||||||
|
|
||||||
|
private static VacancyReportRow RowFor(string recruiter, int vacancies, int closed) =>
|
||||||
|
Row("Позиция", vacancies: vacancies, closed: closed, recruiter: recruiter);
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: горизонтальная таблица — рекрутёры по убыванию объёма, с процентами")]
|
||||||
|
public void SummarySheet_HorizontalTable()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Надежда", vacancies: 10, closed: 4), // в работе 60%, закрыто 40%
|
||||||
|
RowFor("Алтынай", vacancies: 5, closed: 5), // закрыто 100%
|
||||||
|
]));
|
||||||
|
var sheet = workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
StringAt(sheet, 1, 1).ShouldBe("результаты");
|
||||||
|
|
||||||
|
// Заголовок: «Кол-во вакансий» + рекрутёры по убыванию объёма (Надежда 10 > Алтынай 5).
|
||||||
|
StringAt(sheet, 2, 1).ShouldBe("Кол-во вакансий");
|
||||||
|
StringAt(sheet, 2, 2).ShouldBe("Надежда");
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe("Алтынай");
|
||||||
|
|
||||||
|
StringAt(sheet, 3, 1).ShouldBe("Всего");
|
||||||
|
IntAt(sheet, 3, 2).ShouldBe(10);
|
||||||
|
IntAt(sheet, 3, 3).ShouldBe(5);
|
||||||
|
|
||||||
|
StringAt(sheet, 4, 1).ShouldBe("в работе (в%)");
|
||||||
|
CellAt(sheet, 4, 2)!.NumericCellValue.ShouldBe(0.6, 0.0001);
|
||||||
|
CellAt(sheet, 4, 3)!.NumericCellValue.ShouldBe(0.0, 0.0001);
|
||||||
|
|
||||||
|
StringAt(sheet, 5, 1).ShouldBe("закрыто");
|
||||||
|
CellAt(sheet, 5, 2)!.NumericCellValue.ShouldBe(0.4, 0.0001);
|
||||||
|
CellAt(sheet, 5, 3)!.NumericCellValue.ShouldBe(1.0, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: вакансии без рекрутёра сведены в «Без рекрутёра» и идут последними")]
|
||||||
|
public void SummarySheet_UnassignedRecruiterLast()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Алтынай", vacancies: 1, closed: 0),
|
||||||
|
Row("Без исполнителя", vacancies: 100, closed: 0, recruiter: null),
|
||||||
|
]));
|
||||||
|
var sheet = workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
// «Без рекрутёра» — последняя колонка горизонтальной таблицы, несмотря на
|
||||||
|
// то, что её объём (100) больше, чем у Алтынай (1): это не участник
|
||||||
|
// рейтинга, а технический хвост.
|
||||||
|
StringAt(sheet, 2, 2).ShouldBe("Алтынай");
|
||||||
|
StringAt(sheet, 2, 3).ShouldBe(VacancyReportSummary.UnassignedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: вертикальные таблицы — доля каждого рекрутёра в общем объёме и в работе")]
|
||||||
|
public void SummarySheet_VerticalTables()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Надежда", vacancies: 3, closed: 0), // весь объём в работе
|
||||||
|
RowFor("Алтынай", vacancies: 1, closed: 1), // весь объём закрыт
|
||||||
|
]));
|
||||||
|
var sheet = workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
// Таблица 1 — «Общий объем работ»: строка 7 (после шапки+4 строк
|
||||||
|
// горизонтальной таблицы+пустой строки), итого 4 (3+1), доля Надежды 75%.
|
||||||
|
StringAt(sheet, 7, 1).ShouldBe("Общий объем работ");
|
||||||
|
IntAt(sheet, 7, 2).ShouldBe(4);
|
||||||
|
StringAt(sheet, 8, 1).ShouldBe("Надежда");
|
||||||
|
CellAt(sheet, 8, 2)!.NumericCellValue.ShouldBe(0.75, 0.0001);
|
||||||
|
StringAt(sheet, 9, 1).ShouldBe("Алтынай");
|
||||||
|
CellAt(sheet, 9, 2)!.NumericCellValue.ShouldBe(0.25, 0.0001);
|
||||||
|
|
||||||
|
// Таблица 2 — «Общий объем в работе»: строка 11, итого 3 (только Надежда
|
||||||
|
// не закрыта), её доля — 100%; у Алтынай в работе 0 → доля 0%.
|
||||||
|
StringAt(sheet, 11, 1).ShouldBe("Общий объем в работе");
|
||||||
|
IntAt(sheet, 11, 2).ShouldBe(3);
|
||||||
|
StringAt(sheet, 12, 1).ShouldBe("Надежда");
|
||||||
|
CellAt(sheet, 12, 2)!.NumericCellValue.ShouldBe(1.0, 0.0001);
|
||||||
|
StringAt(sheet, 13, 1).ShouldBe("Алтынай");
|
||||||
|
CellAt(sheet, 13, 2)!.NumericCellValue.ShouldBe(0.0, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: под каждой вертикальной таблицей — своя круговая диаграмма")]
|
||||||
|
public void SummarySheet_HasTwoPieCharts()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(
|
||||||
|
new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31),
|
||||||
|
new VacancyReportGroup("", [
|
||||||
|
RowFor("Надежда", vacancies: 3, closed: 1),
|
||||||
|
RowFor("Алтынай", vacancies: 2, closed: 0),
|
||||||
|
]));
|
||||||
|
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
var charts = sheet.GetDrawingPatriarch()?.GetCharts();
|
||||||
|
charts.ShouldNotBeNull();
|
||||||
|
charts!.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Результаты: нет вакансий за период — без таблиц и диаграмм, без падения")]
|
||||||
|
public void SummarySheet_EmptyReport_NoTablesOrCharts()
|
||||||
|
{
|
||||||
|
var workbook = BuildWorkbook(new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 31));
|
||||||
|
var sheet = (XSSFSheet)workbook.GetSheet("Результаты");
|
||||||
|
|
||||||
|
sheet.GetDrawingPatriarch().ShouldBeNull();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user