using ClosedXML.Excel; namespace Tnsu.ApprovalRoute.Application.Reports; /// /// Отчёт по вакансиям в виде xlsx — лист «Список вакансий (все)» по образцу /// ручного отчёта HR: те же шестнадцать колонок A…P в том же порядке, строки /// разделители по объектам, итоги внизу. /// /// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное, /// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой /// пришлось бы править на каждое изменение состава вакансий. /// /// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся /// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после /// правки строки цифры должны пересчитаться сами. /// public static class VacancyReportWorkbook { private const string SheetName = "Список вакансий (все)"; // Цвет шапки — из образца отчёта (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 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), ]; // Номера колонок — именами, чтобы формулы и стили не разъезжались с Columns. private const int ColTotalNumber = 1; private const int ColGroupNumber = 2; private const int ColPosition = 3; private const int ColDepartment = 4; private const int ColVacancies = 5; private const int ColClosed = 6; private const int ColInProgress = 7; private const int ColSubmitDate = 8; private const int ColClosingDate = 9; private const int ColLeadTime = 10; private const int ColStartDate = 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; private const int HeaderRow = 1; public static byte[] Build(VacancyReport report) { using var workbook = new XLWorkbook(); var sheet = workbook.AddWorksheet(SheetName); WriteHeader(sheet); 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(); workbook.SaveAs(stream); return stream.ToArray(); } /// Имя файла — с периодом, как в ручном отчёте HR. public static string FileName(VacancyReport report) => $"Отчет по вакансиям {report.From:dd.MM.yyyy}-{report.To:dd.MM.yyyy}.xlsx"; private static void WriteHeader(IXLWorksheet sheet) { for (var i = 0; i < Columns.Length; i++) { var (title, width) = Columns[i]; var column = i + 1; sheet.Cell(HeaderRow, column).Value = title; sheet.Column(column).Width = width; } var header = sheet.Range(HeaderRow, 1, HeaderRow, Columns.Length); 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; // Заголовки длинные и переносятся — без заданной высоты Excel показал бы // одну строку из трёх (высота 90 — из образца отчёта). sheet.Row(HeaderRow).Height = 90; } /// /// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в /// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё /// начинается заново — так же, как в ручном отчёте. /// private static void WriteGroupSeparator(IXLWorksheet sheet, int row, string name) { sheet.Cell(row, ColGroupNumber).Value = name; var range = sheet.Range(row, 1, row, Columns.Length); range.Style.Fill.BackgroundColor = GroupFill; range.Style.Font.Bold = true; } private static void WriteRow(IXLWorksheet sheet, int row, int totalNumber, int groupNumber, VacancyReportRow item) { sheet.Cell(row, ColTotalNumber).Value = totalNumber; sheet.Cell(row, ColGroupNumber).Value = groupNumber; 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); SetDate(sheet.Cell(row, ColClosingDate), item.ClosingDate); // «Срок отработки» — разница дат в днях, и только когда известны обе: // иначе Excel вычел бы из пустой ячейки ноль и показал бы срок в 46 тысяч // дней (пустая ячейка — это 00.01.1900). if (item.SubmitDate is not null && item.ClosingDate is not null) { sheet.Cell(row, ColLeadTime).FormulaA1 = $"={Letter(ColClosingDate)}{row}-{Letter(ColSubmitDate)}{row}"; sheet.Cell(row, ColLeadTime).Style.NumberFormat.Format = "0"; } SetDate(sheet.Cell(row, ColStartDate), item.StartDate); sheet.Cell(row, ColInitiator).Value = item.Initiator ?? string.Empty; sheet.Cell(row, ColReplaced).Value = item.ReplacedEmployee ?? string.Empty; // Колонка «Комментарии» остаётся пустой намеренно: в ручном отчёте это // пометки рекрутёра («в подборе», «на стопе»), которым в заявках сейчас // негде храниться. Колонку оставляем, чтобы её дописывали в выгрузке. sheet.Cell(row, ColClosedFlag).Value = item.ClosedFlag; sheet.Cell(row, ColRecruiter).Value = 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) { foreach (var column in (int[])[ColVacancies, ColClosed, ColInProgress]) sheet.Cell(row, column).FormulaA1 = $"=SUM({Letter(column)}{firstDataRow}:{Letter(column)}{lastDataRow})"; var range = sheet.Range(row, 1, row, Columns.Length); range.Style.Font.Bold = true; range.Style.Border.TopBorder = XLBorderStyleValues.Double; } /// /// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её /// захватывать не должен, иначе при любом фильтре итоги уезжают из вида /// вместе с отфильтрованными строками. /// private static void Finish(IXLWorksheet sheet, int lastDataRow) { // Даты — по всей колонке, а не по заполненным ячейкам: формат должен // остаться и у строк, которые допишут руками. foreach (var column in (int[])[ColSubmitDate, ColClosingDate, ColStartDate]) sheet.Column(column).Style.NumberFormat.Format = DateFormat; // Шапка закреплена — лист длинный, без этого при прокрутке не видно, // какая колонка какая. sheet.SheetView.FreezeRows(HeaderRow); if (lastDataRow > HeaderRow) sheet.Range(HeaderRow, 1, lastDataRow, Columns.Length).SetAutoFilter(); } private static void SetDate(IXLCell cell, DateOnly? date) { if (date is null) return; cell.Value = date.Value.ToDateTime(TimeOnly.MinValue); cell.Style.NumberFormat.Format = DateFormat; } private static string Letter(int column) => XLHelper.GetColumnLetterFromNumber(column); }