Отчет по вакансиям (#20)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/20
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
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),
|
||||
("Ф.И.О. нанимаемого сотрудника", 30.0),
|
||||
("Инициатор заявки", 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 ColCandidateName = 11;
|
||||
private const int ColInitiator = 12;
|
||||
private const int ColReplaced = 13;
|
||||
private const int ColComment = 14;
|
||||
private const int ColClosedFlag = 15;
|
||||
private const int ColRecruiter = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные
|
||||
/// общие — при нескольких приёмах по одной заявке на подбор они объединяются
|
||||
/// по вертикали (см. WriteVacancy).
|
||||
/// </summary>
|
||||
private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced];
|
||||
|
||||
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)
|
||||
rowIdx += WriteVacancy(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Одна вакансия — столько строк, сколько у неё Lines (минимум одна).
|
||||
/// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик.
|
||||
///
|
||||
/// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по
|
||||
/// вертикали: у объединённой области Excel хранит значение в верхней левой
|
||||
/// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя —
|
||||
/// SUM в итогах посчитал бы одно и то же несколько раз).
|
||||
/// </summary>
|
||||
private static int WriteVacancy(
|
||||
XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item)
|
||||
{
|
||||
var lines = item.Lines;
|
||||
var lineCount = Math.Max(lines.Count, 1);
|
||||
var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки.
|
||||
|
||||
// Все строки вакансии — со стилем данных, иначе у продолжений не будет
|
||||
// границ и объединённая область выглядит рваной.
|
||||
for (var offset = 0; offset < lineCount; offset++)
|
||||
{
|
||||
var row = sheet.CreateRow(firstRowIdx + offset);
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
row.CreateCell(col).CellStyle = styles.Data;
|
||||
}
|
||||
|
||||
var first = sheet.GetRow(firstRowIdx);
|
||||
|
||||
first.GetCell(ColTotalNumber).SetCellValue(totalNumber);
|
||||
first.GetCell(ColGroupNumber).SetCellValue(groupNumber);
|
||||
first.GetCell(ColPosition).SetCellValue(item.Position);
|
||||
first.GetCell(ColDepartment).SetCellValue(item.Department ?? string.Empty);
|
||||
first.GetCell(ColVacancies).SetCellValue(item.VacanciesCount);
|
||||
first.GetCell(ColClosed).SetCellValue(item.ClosedCount);
|
||||
first.GetCell(ColInProgress).SetCellFormula(
|
||||
$"{ColLetter(ColVacancies)}{excelRow}-{ColLetter(ColClosed)}{excelRow}");
|
||||
|
||||
SetDate(first.GetCell(ColSubmitDate), styles, item.SubmitDate);
|
||||
SetDate(first.GetCell(ColClosingDate), styles, item.ClosingDate);
|
||||
|
||||
// «Срок отработки» — разница дат в днях, и только когда известны обе:
|
||||
// иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч
|
||||
// дней (пустая ячейка — это 00.01.1900).
|
||||
if (item.SubmitDate is not null && item.ClosingDate is not null)
|
||||
{
|
||||
var leadTimeCell = first.GetCell(ColLeadTime);
|
||||
leadTimeCell.CellStyle = styles.DataLeadTime;
|
||||
leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}");
|
||||
}
|
||||
|
||||
first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty);
|
||||
// Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном
|
||||
// отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в
|
||||
// заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали
|
||||
// в выгрузке.
|
||||
first.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag);
|
||||
first.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty);
|
||||
|
||||
// Построчные поля — каждое в свою строку.
|
||||
for (var offset = 0; offset < lines.Count; offset++)
|
||||
{
|
||||
var row = sheet.GetRow(firstRowIdx + offset);
|
||||
var line = lines[offset];
|
||||
|
||||
row.GetCell(ColCandidateName).SetCellValue(line.CandidateName ?? string.Empty);
|
||||
SetDate(row.GetCell(ColStartDate), styles, line.StartDate);
|
||||
row.GetCell(ColReplaced).SetCellValue(line.ReplacedEmployee ?? string.Empty);
|
||||
}
|
||||
|
||||
// Объединяем общие колонки только когда строк действительно больше одной:
|
||||
// область 1×1 Excel считает некорректной и портит файл.
|
||||
if (lineCount > 1)
|
||||
{
|
||||
var lastRowIdx = firstRowIdx + lineCount - 1;
|
||||
for (var col = 0; col < Columns.Length; col++)
|
||||
{
|
||||
if (PerLineColumns.Contains(col)) continue;
|
||||
sheet.AddMergedRegion(new CellRangeAddress(firstRowIdx, lastRowIdx, col, col));
|
||||
}
|
||||
}
|
||||
|
||||
return lineCount;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// ВАЖНО: даты НЕ переносятся сюда через SetDefaultColumnStyle — NPOI (в
|
||||
// отличие от чистого OOXML, где стиль колонки должен уступать явному
|
||||
// стилю ячейки) переписывает им стиль уже существующих ячеек колонки
|
||||
// целиком, включая шапку: заголовки «Дата подачи заявки»/«Дата закрытия
|
||||
// вакансии»/«Дата выхода» лишались синей заливки и получали формат даты
|
||||
// вместо неё. Формат даты у самих дат и так стоит явно, ячейка за
|
||||
// ячейкой (см. SetDate) — колонки, дописанные в файл руками ниже данных,
|
||||
// формат не унаследуют, но это не тот случай, ради которого стоит опять
|
||||
// ломать шапку.
|
||||
//
|
||||
// Шапка закреплена — лист длинный, без этого при прокрутке не видно,
|
||||
// какая колонка какая.
|
||||
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));
|
||||
|
||||
// Якоря диаграмм считаются НЕЗАВИСИМО от строк таблиц: у таблицы может
|
||||
// быть 2 строки, а у диаграммы фиксированная высота ChartHeightRows — с
|
||||
// якорем "на уровне шапки своей таблицы" вторая диаграмма при коротких
|
||||
// таблицах вставала бы внутрь вертикального диапазона первой (см.
|
||||
// ChartHeightRows). Обе выравнены по левому краю первой (горизонтальной)
|
||||
// таблицы и просто идут подряд сверху вниз с отступом.
|
||||
var period = $"с {report.From:dd.MM.yyyy} по {report.To:dd.MM.yyyy}";
|
||||
var chart1TopRowIdx = HeaderRowIdx + 1;
|
||||
var chart2TopRowIdx = chart1TopRowIdx + ChartHeightRows + 1;
|
||||
|
||||
CreatePieChart(
|
||||
sheet, "Объём вакансий " + period,
|
||||
firstDataRowIdx: vertTable1DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart1TopRowIdx);
|
||||
CreatePieChart(
|
||||
sheet, "Объём вакансий в работе " + period,
|
||||
firstDataRowIdx: vertTable2DataStartRowIdx, rowCount: recruiters.Count, anchorTopRowIdx: chart2TopRowIdx);
|
||||
}
|
||||
|
||||
/// <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 col = 2; col <= recruiters.Count; col++)
|
||||
SetCell(headerRow, col, string.Empty, styles.SummaryBlueHeader);
|
||||
|
||||
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>
|
||||
/// Высота якоря диаграммы в строках — фиксированная и не зависит от числа
|
||||
/// рекрутёров: подгонять размер круга под число категорий смысла нет, малое
|
||||
/// число секторов на большом круге читается ничуть не хуже. Используется и
|
||||
/// при размещении диаграмм (см. BuildSummarySheet — вторая идёт сразу под
|
||||
/// первой, без наложения).
|
||||
/// </summary>
|
||||
private const int ChartHeightRows = 14;
|
||||
|
||||
/// <summary>Круговая диаграмма по одной вертикальной таблице: категории — колонка A (имена рекрутёров), значения — колонка B (доля).</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 + ChartHeightRows);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user