24d2cd7a6a
GET api/reports/vacancies?from=&to= отдаёт лист «Список вакансий (все)» по образцу ручного отчёта HR: те же шестнадцать колонок, разделители по объектам, двойная нумерация, итоги формулами. Данные — из двух форм портала: «Заявка на подбор» даёт вакансию, «Приём на работу» — её закрытие (связь по SelectionRequestId + SelectionRequestListId). Рекрутёр и дата закрытия берутся из фазы «Исполнение» парного списка «— согласование»: в самой заявке их нет. Период отбирает заявки по «Дате подачи заявки» с откатом на Created — поле заполняется руками и часто пустое. Заявки на приём периодом не ограничены: приём по вакансии из начала интервала мог оформляться уже после его конца. Колонка «Комментарии» остаётся пустой — в ручном отчёте это пометки рекрутёра, которым в заявках пока негде храниться. Генерация — ClosedXML (MIT): нужны стили, формулы и autofilter, а EPPlus с v5 под Polyform Noncommercial. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
11 KiB
C#
225 lines
11 KiB
C#
using ClosedXML.Excel;
|
|
|
|
namespace Tnsu.ApprovalRoute.Application.Reports;
|
|
|
|
/// <summary>
|
|
/// Отчёт по вакансиям в виде xlsx — лист «Список вакансий (все)» по образцу
|
|
/// ручного отчёта HR: те же шестнадцать колонок A…P в том же порядке, строки
|
|
/// разделители по объектам, итоги внизу.
|
|
///
|
|
/// Лист собирается кодом, а не заполнением файла-шаблона: число строк переменное,
|
|
/// а разделители по объектам сдвигают всё, что ниже — шаблон с такой структурой
|
|
/// пришлось бы править на каждое изменение состава вакансий.
|
|
///
|
|
/// Производные колонки (G «в работе», J «срок отработки») и итоги пишутся
|
|
/// ФОРМУЛАМИ, а не посчитанными числами: отчёт живёт дальше руками, и после
|
|
/// правки строки цифры должны пересчитаться сами.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>Колонка листа: заголовок и ширина (ширины — из образца отчёта).</summary>
|
|
private static readonly (string Title, double Width)[] Columns =
|
|
[
|
|
(" ", 6.1),
|
|
("№", 4.9),
|
|
("Вакансия", 57.4),
|
|
("Подразделение", 17.1),
|
|
("Кол-во вакансий", 8.3),
|
|
("количество закрытых вакансий", 9.1),
|
|
("количество вакансий в работе", 11.6),
|
|
("Дата подачи заявки", 14.7),
|
|
("Дата закрытия вакансии", 14.9),
|
|
("Срок отработки", 12.3),
|
|
("Дата выхода", 13.9),
|
|
("Инициатор заявки", 26.9),
|
|
("Вместо сотрудника Ф.И.О.", 26.0),
|
|
("Комментарии", 51.3),
|
|
("Закрыта вакансия (да/нет/ частично/ пауза)", 18.0),
|
|
("Рекрутер", 23.9),
|
|
];
|
|
|
|
// Номера колонок — именами, чтобы формулы и стили не разъезжались с 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();
|
|
}
|
|
|
|
/// <summary>Имя файла — с периодом, как в ручном отчёте HR.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Строка-разделитель объекта/проекта: имя объекта в колонке «№», как в
|
|
/// образце («КЭС КУРЧАТОВ» в B217). Нумерация внутри группы после неё
|
|
/// начинается заново — так же, как в ручном отчёте.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// lastDataRow — последняя строка ДАННЫХ, без строки итогов: автофильтр её
|
|
/// захватывать не должен, иначе при любом фильтре итоги уезжают из вида
|
|
/// вместе с отфильтрованными строками.
|
|
/// </summary>
|
|
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);
|
|
}
|