diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs index fc65e46..ead3c19 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReport.cs @@ -14,33 +14,54 @@ public sealed record VacancyReport(DateOnly From, DateOnly To, IReadOnlyList Rows); /// -/// Одна вакансия — строка листа. Колонки A/B (нумерация), G (в работе), J (срок -/// отработки) и итоги здесь не хранятся: их считает сам xlsx формулами, чтобы -/// цифры пересчитывались, если строку в файле поправят руками. +/// Одна ВАКАНСИЯ (заявка на подбор). Поля этой записи — ОБЩИЕ для всех её строк +/// на листе, там они объединяются по вертикали; то, что различается от приёма к +/// приёму, лежит в Lines. +/// +/// Колонки нумерации, «в работе», «срок отработки» и итоги здесь не хранятся: +/// их считает сам xlsx формулами, чтобы цифры пересчитывались, если строку в +/// файле поправят руками. /// public sealed record VacancyReportRow( - // C — «Вакансия»: должность из заявки на подбор. + // «Вакансия» — должность из заявки на подбор. string Position, - // D — «Подразделение» ответственного за подачу заявки. + // «Подразделение» ответственного за подачу заявки. string? Department, - // E — «Кол-во вакансий». + // «Кол-во вакансий». int VacanciesCount, - // F — «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор. + // «количество закрытых вакансий»: сколько заявок на приём завели по этой заявке на подбор. int ClosedCount, - // H — «Дата подачи заявки». + // «Дата подачи заявки». DateOnly? SubmitDate, - // I — «Дата закрытия вакансии»: дата решения по последнему шагу исполнения. + // «Дата закрытия вакансии»: дата решения по последнему шагу исполнения. DateOnly? ClosingDate, - // K — «Дата выхода»: самая поздняя дата приёма среди принятых по этой вакансии. - DateOnly? StartDate, - // L — «Инициатор заявки». + // «Инициатор заявки». string? Initiator, - // M — «Вместо сотрудника Ф.И.О.». - string? ReplacedEmployee, - // O — «Закрыта вакансия»: да / нет / частично. + // «Закрыта вакансия»: да / нет / частично. string ClosedFlag, - // P — «Рекрутер»: исполнитель фазы исполнения заявки на подбор. - string? Recruiter); + // «Рекрутер»: исполнитель фазы исполнения заявки на подбор. + string? Recruiter, + // Строки листа по этой вакансии — ВСЕГДА не меньше одной (см. VacancyReportLine). + IReadOnlyList Lines); + +/// +/// Одна СТРОКА ЛИСТА в пределах вакансии — то, что различается между её строками. +/// Обычно это один приём: на заявку на подбор «Разнорабочий, 4 вакансии» +/// приходится четыре заявки на приём, у каждой свой кандидат, своя дата выхода и +/// свой «взамен кого». +/// +/// Именно СТРОКА, а не «приём»: у вакансии, по которой приёмов ещё нет (или все +/// отклонены), строка всё равно одна — с пустыми ФИО и датой выхода, но с +/// заполненным «Вместо сотрудника» из причины открытия вакансии. Реальное число +/// приёмов лежит в VacancyReportRow.ClosedCount, так что информация не теряется. +/// +public sealed record VacancyReportLine( + // «Ф.И.О. нанимаемого сотрудника» — кандидат из заявки на приём. + string? CandidateName, + // «Дата выхода» — дата приёма этого кандидата. + DateOnly? StartDate, + // «Вместо сотрудника Ф.И.О.». + string? ReplacedEmployee); /// Значения колонки O. «Пауза» из образца отчёта не выводится — такого статуса в заявке нет. public static class VacancyClosedFlags diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs index cbfcebd..a03dc40 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportService.cs @@ -227,37 +227,59 @@ public sealed class VacancyReportService( ? found : []; - // Закрытыми считаем места, на которые реально оформили приём: отклонённая - // заявка на приём вакансию не закрывает — кандидат по ней не выходит. - var closed = hires.Count(x => x.Status != HireRequestStatus.Rejected); + // Отклонённые приёмы отбрасываем ЦЕЛИКОМ, а не только из счётчика: такой + // кандидат не выходит, и отдельная строка с его ФИО в отчёте была бы + // ложью («вот кого приняли»). + var accepted = hires.Where(x => x.Status != HireRequestStatus.Rejected).ToList(); return new VacancyReportRow( row.Position ?? string.Empty, row.DepartmentName, row.VacanciesCount, - closed, + accepted.Count, row.SubmitDate, execution.ClosingDate, - hires.Select(x => x.HireDate).Where(x => x is not null).Max(), ShortName(row.Initiator), - ReplacedEmployee(row, hires), - ClosedFlag(row.VacanciesCount, closed), - execution.Recruiter); + ClosedFlag(row.VacanciesCount, accepted.Count), + execution.Recruiter, + Lines(row, accepted)); } /// - /// «Вместо сотрудника» — сначала из заявки на приём (там это отдельное поле - /// «Взамен кого»), иначе из уточнения к причине открытия вакансии, и только - /// если причина — замена уволенного: при «новой штатной единице» в том же - /// поле лежит совсем другой текст. + /// Строки листа по вакансии — по одной на принятого кандидата. Приёмов нет + /// (вакансия в работе или все приёмы отклонены) — всё равно отдаём ОДНУ + /// строку: у вакансии должно быть место на листе, а «Вместо сотрудника» в ней + /// заполняется из причины открытия вакансии. /// - private static string? ReplacedEmployee(SelectionRow row, IReadOnlyList hires) + private static IReadOnlyList Lines(SelectionRow row, IReadOnlyList accepted) { - var fromHires = hires - .Select(x => x.ReplacedEmployee) - .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + if (accepted.Count == 0) + return [new VacancyReportLine(null, null, ReplacedEmployee(row, hire: null))]; - if (fromHires is not null) return fromHires; + return accepted + // Порядок строк внутри вакансии — по дате выхода: так в отчёте видно, + // кто вышел раньше. Приём без даты уходит в конец. + .OrderBy(x => x.HireDate ?? DateOnly.MaxValue) + .ThenBy(x => x.CandidateName, StringComparer.CurrentCulture) + .Select(x => new VacancyReportLine(x.CandidateName, x.HireDate, ReplacedEmployee(row, x))) + .ToList(); + } + + /// + /// «Вместо сотрудника» для КОНКРЕТНОГО приёма — сначала его собственное поле + /// «Взамен кого», иначе уточнение к причине открытия вакансии, и только если + /// причина — замена уволенного: при «новой штатной единице» в том же поле + /// лежит совсем другой текст. + /// + /// Откат берётся из заявки на подбор, то есть у всех приёмов одной вакансии + /// он одинаковый — это нормально: причина открытия у вакансии одна. + /// + /// hire = null — у вакансии приёмов нет вовсе, остаётся только откат. + /// + private static string? ReplacedEmployee(SelectionRow row, HireLinkRow? hire) + { + if (hire is not null && !string.IsNullOrWhiteSpace(hire.ReplacedEmployee)) + return hire.ReplacedEmployee; return row.OpenReason == SelectionOpenReason.Replacement ? row.OpenReasonDetails : null; } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs index 99ee23a..82e6539 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/VacancyReportWorkbook.cs @@ -47,6 +47,7 @@ public static class VacancyReportWorkbook ("Дата закрытия вакансии", 14.9), ("Срок отработки", 12.3), ("Дата выхода", 13.9), + ("Ф.И.О. нанимаемого сотрудника", 30.0), ("Инициатор заявки", 26.9), ("Вместо сотрудника Ф.И.О.", 26.0), ("Комментарии", 51.3), @@ -66,10 +67,19 @@ public static class VacancyReportWorkbook private const int ColClosingDate = 8; private const int ColLeadTime = 9; private const int ColStartDate = 10; - private const int ColInitiator = 11; - private const int ColReplaced = 12; - private const int ColClosedFlag = 14; - private const int ColRecruiter = 15; + private const int 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; + + /// + /// Колонки, которые РАЗЛИЧАЮТСЯ между строками одной вакансии. Все остальные + /// общие — при нескольких приёмах по одной заявке на подбор они объединяются + /// по вертикали (см. WriteVacancy). + /// + private static readonly int[] PerLineColumns = [ColStartDate, ColCandidateName, ColReplaced]; private const int HeaderRowIdx = 0; @@ -108,7 +118,7 @@ public static class VacancyReportWorkbook var groupNumber = 0; foreach (var item in group.Rows) - WriteRow(sheet, styles, rowIdx++, ++totalNumber, ++groupNumber, item); + rowIdx += WriteVacancy(sheet, styles, rowIdx, ++totalNumber, ++groupNumber, item); } var lastDataRowIdx = rowIdx - 1; @@ -151,46 +161,87 @@ public static class VacancyReportWorkbook row.GetCell(ColGroupNumber).SetCellValue(name); } - private static void WriteRow( - XSSFSheet sheet, Styles styles, int rowIdx, int totalNumber, int groupNumber, VacancyReportRow item) + /// + /// Одна вакансия — столько строк, сколько у неё Lines (минимум одна). + /// Возвращает, сколько строк заняла, — вызывающий по этому двигает счётчик. + /// + /// Общие поля пишутся ТОЛЬКО в первую строку, а диапазон объединяется по + /// вертикали: у объединённой области Excel хранит значение в верхней левой + /// ячейке, поэтому дублировать его в остальные строки не нужно (и нельзя — + /// SUM в итогах посчитал бы одно и то же несколько раз). + /// + private static int WriteVacancy( + XSSFSheet sheet, Styles styles, int firstRowIdx, int totalNumber, int groupNumber, VacancyReportRow item) { - var row = sheet.CreateRow(rowIdx); - var excelRow = rowIdx + 1; // A1-нотация формул — 1-based номер строки. + var lines = item.Lines; + var lineCount = Math.Max(lines.Count, 1); + var excelRow = firstRowIdx + 1; // A1-нотация формул — 1-based номер строки. - for (var col = 0; col < Columns.Length; col++) - row.CreateCell(col).CellStyle = styles.Data; + // Все строки вакансии — со стилем данных, иначе у продолжений не будет + // границ и объединённая область выглядит рваной. + 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; + } - 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}"); + var first = sheet.GetRow(firstRowIdx); - SetDate(row.GetCell(ColSubmitDate), styles, item.SubmitDate); - SetDate(row.GetCell(ColClosingDate), styles, item.ClosingDate); + 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 = row.GetCell(ColLeadTime); + var leadTimeCell = first.GetCell(ColLeadTime); leadTimeCell.CellStyle = styles.DataLeadTime; leadTimeCell.SetCellFormula($"{ColLetter(ColClosingDate)}{excelRow}-{ColLetter(ColSubmitDate)}{excelRow}"); } - SetDate(row.GetCell(ColStartDate), styles, item.StartDate); - - row.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty); - row.GetCell(ColReplaced).SetCellValue(item.ReplacedEmployee ?? string.Empty); - // Колонка «Комментарии» (ColComment, 13) остаётся пустой намеренно: в - // ручном отчёте это пометки рекрутёра («в подборе», «на стопе»), которым - // в заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали + first.GetCell(ColInitiator).SetCellValue(item.Initiator ?? string.Empty); + // Колонка «Комментарии» (ColComment) остаётся пустой намеренно: в ручном + // отчёте это пометки рекрутёра («в подборе», «на стопе»), которым в + // заявках сейчас негде храниться. Колонку оставляем, чтобы её дописывали // в выгрузке. - row.GetCell(ColClosedFlag).SetCellValue(item.ClosedFlag); - row.GetCell(ColRecruiter).SetCellValue(item.Recruiter ?? string.Empty); + 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) diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs index 9b5d700..ee7e3d9 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportServiceTests.cs @@ -107,14 +107,15 @@ public sealed class VacancyReportServiceTests : IClassFixture + string? replaced = null, + string? candidate = "Шакенов Анель") => new { Id = selectionId * 10, RequestStatus = status, SelectionRequestId = (double)selectionId, SelectionRequestListId = selectionListUrl, - CandidateName = "Шакенов Анель", + CandidateName = candidate, ReplacedEmployee = replaced, HireDate = hireDate }; @@ -195,9 +196,11 @@ public sealed class VacancyReportServiceTests : IClassFixture l.StartDate).ShouldBe([new DateOnly(2026, 8, 5), new DateOnly(2026, 8, 25)]); + row.Lines.Select(l => l.CandidateName).ShouldBe(["Дулин Евгений", "Бутиев Азамат"]); + } + + [Fact(DisplayName = "Отклонённый приём не даёт строки — кандидата в отчёте быть не должно")] + public async Task RejectedHire_ProducesNoLine() + { + const string name = "rejectedLine"; + var list = SelectionListUrl(name); + var (service, _) = await BuildAsync( + name, + [Selection(1, "Разнорабочий", vacancies: 2)], + [Execution(1)], + [ + Hire(1, list, candidate: "Принятый Кандидат"), + Hire(1, list, status: "Отклонено", candidate: "Отклонённый Кандидат") + ]); + + var result = await service.GetAsync(From, To, CancellationToken.None); + + var row = SingleRow(result.Value); + row.ClosedCount.ShouldBe(1); + row.Lines.ShouldHaveSingleItem().CandidateName.ShouldBe("Принятый Кандидат"); } [Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")] @@ -344,9 +373,28 @@ public sealed class VacancyReportServiceTests : IClassFixture r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик"); - rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull(); + rows.Single(r => r.Position == "Начальник геодезии") + .Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Серик"); + rows.Single(r => r.Position == "Инженер ВиВ") + .Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBeNull(); + } + + [Fact(DisplayName = "«Взамен кого» из заявки на приём важнее причины открытия вакансии")] + public async Task ReplacedEmployee_HireWins() + { + const string name = "replacedHire"; + var (service, _) = await BuildAsync( + name, + [Selection(1, "Юрист", openReason: "Замена уволенного сотрудника", openReasonDetails: "Из заявки")], + [Execution(1)], + [Hire(1, SelectionListUrl(name), replaced: "Из приёма")]); + + var result = await service.GetAsync(From, To, CancellationToken.None); + + SingleRow(result.Value).Lines.ShouldHaveSingleItem().ReplacedEmployee.ShouldBe("Из приёма"); } [Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")] diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs index 6861295..2bdd4b6 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/VacancyReportWorkbookTests.cs @@ -21,7 +21,8 @@ public sealed class VacancyReportWorkbookTests DateOnly? submitDate = null, DateOnly? closingDate = null, DateOnly? startDate = null, - string? recruiter = "Сергеева Надежда Ивановна") => + string? recruiter = "Сергеева Надежда Ивановна", + IReadOnlyList? lines = null) => new( position, "офис", @@ -29,11 +30,16 @@ public sealed class VacancyReportWorkbookTests closed, submitDate ?? new DateOnly(2026, 7, 8), closingDate, - startDate, "Ким Н.Ю.", - null, closed == 0 ? VacancyClosedFlags.No : VacancyClosedFlags.Yes, - recruiter); + recruiter, + // По умолчанию одна строка — вакансия без приёмов либо с одним; + // тесты многострочности передают lines явно. + lines ?? [new VacancyReportLine(null, startDate, null)]); + + /// Строка листа в пределах вакансии — то, что различается между приёмами. + private static VacancyReportLine Line(string? candidate, DateOnly? startDate = null, string? replaced = null) => + new(candidate, startDate, replaced); private static XSSFWorkbook BuildWorkbook(DateOnly from, DateOnly to, params VacancyReportGroup[] groups) { @@ -72,7 +78,7 @@ public sealed class VacancyReportWorkbookTests return cell?.CellType == CellType.Formula ? cell.CellFormula : ""; } - [Fact(DisplayName = "Шапка — шестнадцать колонок в порядке образца отчёта")] + [Fact(DisplayName = "Шапка — семнадцать колонок в порядке образца отчёта")] public void Header_MatchesSample() { var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); @@ -84,10 +90,11 @@ public sealed class VacancyReportWorkbookTests StringAt(sheet, 1, 8).ShouldBe("Дата подачи заявки"); StringAt(sheet, 1, 9).ShouldBe("Дата закрытия вакансии"); StringAt(sheet, 1, 11).ShouldBe("Дата выхода"); - StringAt(sheet, 1, 12).ShouldBe("Инициатор заявки"); - StringAt(sheet, 1, 14).ShouldBe("Комментарии"); - StringAt(sheet, 1, 16).ShouldBe("Рекрутер"); - IsEmptyAt(sheet, 1, 17).ShouldBeTrue(); + StringAt(sheet, 1, 12).ShouldBe("Ф.И.О. нанимаемого сотрудника"); + StringAt(sheet, 1, 13).ShouldBe("Инициатор заявки"); + StringAt(sheet, 1, 15).ShouldBe("Комментарии"); + StringAt(sheet, 1, 17).ShouldBe("Рекрутер"); + IsEmptyAt(sheet, 1, 18).ShouldBeTrue(); } [Fact(DisplayName = "Шапка колонок с датами закрашена так же, как остальная шапка")] @@ -124,11 +131,76 @@ public sealed class VacancyReportWorkbookTests DateAt(sheet, 2, 8).ShouldBe(new DateTime(2026, 3, 30)); DateAt(sheet, 2, 9).ShouldBe(new DateTime(2026, 7, 8)); DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 17)); - StringAt(sheet, 2, 12).ShouldBe("Ким Н.Ю."); + StringAt(sheet, 2, 13).ShouldBe("Ким Н.Ю."); // «Комментарии» намеренно пусты — в заявках этих пометок пока нет. - IsEmptyAt(sheet, 2, 14).ShouldBeTrue(); - StringAt(sheet, 2, 15).ShouldBe("да"); - StringAt(sheet, 2, 16).ShouldBe("Сергеева Надежда Ивановна"); + IsEmptyAt(sheet, 2, 15).ShouldBeTrue(); + StringAt(sheet, 2, 16).ShouldBe("да"); + StringAt(sheet, 2, 17).ShouldBe("Сергеева Надежда Ивановна"); + } + + [Fact(DisplayName = "Несколько приёмов: своя строка на каждого, общие поля объединены по вертикали")] + public void SeveralHires_OneRowEachWithMergedCommonCells() + { + var sheet = BuildListSheet(new VacancyReportGroup("", [ + Row("Разнорабочий", vacancies: 4, closed: 4, + submitDate: new DateOnly(2026, 7, 10), + lines: [ + Line("Дулин Евгений Петрович", new DateOnly(2026, 8, 19)), + Line("Телятников Игорь Сергеевич", new DateOnly(2026, 8, 21)), + Line("Жабаев Нурлан Ахметович", new DateOnly(2026, 8, 25), replaced: "Оспанов К.") + ]) + ])); + + // Построчные колонки — своё значение в каждой строке. + StringAt(sheet, 2, 12).ShouldBe("Дулин Евгений Петрович"); + StringAt(sheet, 3, 12).ShouldBe("Телятников Игорь Сергеевич"); + StringAt(sheet, 4, 12).ShouldBe("Жабаев Нурлан Ахметович"); + DateAt(sheet, 2, 11).ShouldBe(new DateTime(2026, 8, 19)); + DateAt(sheet, 3, 11).ShouldBe(new DateTime(2026, 8, 21)); + DateAt(sheet, 4, 11).ShouldBe(new DateTime(2026, 8, 25)); + StringAt(sheet, 4, 14).ShouldBe("Оспанов К."); + + // Общие — значение ТОЛЬКО в первой строке: у объединённой области Excel + // хранит его в верхней левой ячейке, и дубли сломали бы SUM в итогах. + StringAt(sheet, 2, 3).ShouldBe("Разнорабочий"); + IsEmptyAt(sheet, 3, 3).ShouldBeTrue(); + IsEmptyAt(sheet, 4, 3).ShouldBeTrue(); + IntAt(sheet, 2, 5).ShouldBe(4); + IsEmptyAt(sheet, 3, 5).ShouldBeTrue(); + + // И сами объединения заведены — иначе продолжения выглядели бы пустыми. + var merged = Enumerable.Range(0, sheet.NumMergedRegions).Select(sheet.GetMergedRegion).ToList(); + merged.ShouldContain(r => r.FirstRow == 1 && r.LastRow == 3 && r.FirstColumn == 2 && r.LastColumn == 2); + // Построчные колонки объединять нельзя — иначе ФИО второго и третьего пропали бы. + merged.ShouldNotContain(r => r.FirstColumn == 11); // 0-based: ColCandidateName + merged.ShouldNotContain(r => r.FirstColumn == 10); // ColStartDate + } + + [Fact(DisplayName = "Одна строка у вакансии — объединений не создаётся (область 1×1 портит файл)")] + public void SingleLine_NoMergedRegions() + { + var sheet = BuildListSheet(new VacancyReportGroup("", [Row("Логист")])); + + sheet.NumMergedRegions.ShouldBe(0); + } + + [Fact(DisplayName = "Итоги суммируют вакансии по одному разу, а не по числу приёмов")] + public void Totals_CountVacancyOnce_NotPerHire() + { + var sheet = BuildListSheet(new VacancyReportGroup("", [ + Row("Разнорабочий", vacancies: 4, closed: 4, lines: [ + Line("Первый", new DateOnly(2026, 8, 19)), + Line("Второй", new DateOnly(2026, 8, 21)) + ]), + Row("Логист", vacancies: 1, closed: 0) + ])); + + // Строки: 2-3 «Разнорабочий» (две), 4 «Логист» → итоги в 5-й. + FormulaAt(sheet, 5, 5).ShouldBe("SUM(E2:E4)"); + // Значение «Кол-во вакансий» лежит только в E2 и E4 — E3 пустая (объединение), + // поэтому SUM даёт 4+1=5, а не 4+4+1. + IsEmptyAt(sheet, 3, 5).ShouldBeTrue(); + IntAt(sheet, 4, 5).ShouldBe(1); } [Fact(DisplayName = "«В работе» и «срок отработки» — формулы, а не посчитанные числа")]