240e5984b1
На одну заявку на подбор приходится несколько заявок на приём («Разнорабочий, 4 вакансии» → 4 кандидата), и раньше они схлопывались в одну строку: ФИО не было вовсе, а «Дата выхода» показывала только САМУЮ ПОЗДНЮЮ из четырёх. Кто именно вышел и когда — из отчёта было не узнать. Теперь вакансия занимает столько строк, сколько по ней приёмов. Построчные колонки — «Дата выхода», новая «Ф.И.О. нанимаемого сотрудника» и «Вместо сотрудника Ф.И.О.» (у каждого приёма своё «взамен кого»); все остальные общие и объединяются по вертикали. Модель: VacancyReportRow.Lines — именно СТРОКИ ЛИСТА, а не «приёмы». У вакансии без приёмов строка всё равно одна (иначе она пропала бы из отчёта), с пустыми ФИО и датой, но с «Вместо сотрудника» из причины открытия вакансии — прежнее поведение сохранено. Реальное число приёмов осталось в ClosedCount. Отклонённые приёмы теперь отбрасываются целиком, а не только из счётчика: отдельная строка с ФИО отклонённого кандидата утверждала бы «вот кого приняли». Объединение заводится только при lineCount > 1 — область 1×1 Excel считает некорректной и портит файл. Значение общих колонок пишется ТОЛЬКО в первую строку: у объединённой области Excel хранит его в верхней левой ячейке, а дубли сломали бы SUM в итогах (посчитал бы вакансию по разу на каждый приём) — на это есть отдельный тест. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
432 lines
20 KiB
C#
432 lines
20 KiB
C#
using System.Text.Json;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Microsoft.Extensions.Options;
|
||
using Moq;
|
||
using Shouldly;
|
||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||
using Tnsu.ApprovalRoute.Application.Reports;
|
||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||
|
||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||
|
||
/// <summary>
|
||
/// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза
|
||
/// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и
|
||
/// когда приняли).
|
||
///
|
||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности
|
||
/// дробные «1.0» у Number-колонок).
|
||
/// </summary>
|
||
public sealed class VacancyReportServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||
{
|
||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||
private const string DataListTitle = "Заявка на подбор — данные";
|
||
private const string ApprovalListTitle = "Заявка на подбор — согласование";
|
||
|
||
private static readonly DateOnly From = new(2026, 7, 1);
|
||
private static readonly DateOnly To = new(2026, 8, 31);
|
||
|
||
private readonly ServiceProvider _services;
|
||
private readonly RequestTypeService _requestTypeService;
|
||
|
||
public VacancyReportServiceTests(PostgresDatabaseFixture fixture)
|
||
{
|
||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||
}
|
||
|
||
// База у тестов класса общая, а сервис ищет тип заявки по PageUrl — поэтому
|
||
// у каждого теста свои списки и PageUrl (оба выводятся из имени теста), и
|
||
// тесты не видят записи друг друга.
|
||
private static string SelectionListUrl(string name) => $"/sites/requests/Lists/Подбор-{name}";
|
||
private static string HireListUrl(string name) => $"/sites/requests/Lists/Приём-{name}";
|
||
|
||
private async Task CreateTypeAsync(string name, string listUrl, string pageUrl) =>
|
||
await _requestTypeService.CreateAsync(
|
||
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
|
||
CancellationToken.None);
|
||
|
||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||
|
||
/// <summary>Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST.</summary>
|
||
private static object Selection(
|
||
int id,
|
||
string position,
|
||
string? objectProject = null,
|
||
double vacancies = 1,
|
||
string? submitDate = "2026-07-08T00:00:00Z",
|
||
string? created = "2026-07-08T00:00:00Z",
|
||
string? responsible = "Ким Наталья Юрьевна",
|
||
string? openReason = null,
|
||
string? openReasonDetails = null) =>
|
||
new
|
||
{
|
||
Id = id,
|
||
RequestStatus = "Выполнение",
|
||
Position = position,
|
||
DepartmentName = "офис",
|
||
ObjectProject = objectProject,
|
||
// Number-колонка приходит дробной («1.0») — тест держит её такой же.
|
||
OpenVacanciesCount = vacancies,
|
||
SubmitDate = submitDate,
|
||
Created = created,
|
||
OpenReason = openReason,
|
||
OpenReasonDetails = openReasonDetails,
|
||
Responsible = new { Title = responsible }
|
||
};
|
||
|
||
/// <summary>Строка фазы «Исполнение» из списка «Заявка на подбор — согласование».</summary>
|
||
private static object Execution(
|
||
int ticketId,
|
||
string status = "Выполнено",
|
||
string? date = "2026-07-22T10:00:00Z",
|
||
double stepOrder = 1,
|
||
string? executor = "Сергеева Надежда Ивановна") =>
|
||
new
|
||
{
|
||
Id = ticketId * 100 + (int)stepOrder,
|
||
TicketId = ticketId,
|
||
ApprovalStatus = status,
|
||
StepOrder = stepOrder,
|
||
Date = date,
|
||
TansuSiteApprover = new { Title = executor }
|
||
};
|
||
|
||
/// <summary>
|
||
/// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в
|
||
/// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по
|
||
/// одному Id — в разных партициях Id повторяются.
|
||
/// </summary>
|
||
private static object Hire(
|
||
int selectionId,
|
||
string selectionListUrl,
|
||
string status = "Закрыта",
|
||
string? hireDate = "2026-08-17T00:00:00Z",
|
||
string? replaced = null,
|
||
string? candidate = "Шакенов Анель") =>
|
||
new
|
||
{
|
||
Id = selectionId * 10,
|
||
RequestStatus = status,
|
||
SelectionRequestId = (double)selectionId,
|
||
SelectionRequestListId = selectionListUrl,
|
||
CandidateName = candidate,
|
||
ReplacedEmployee = replaced,
|
||
HireDate = hireDate
|
||
};
|
||
|
||
private async Task<(VacancyReportService Service, Mock<ISharePointClient> SharePoint)> BuildAsync(
|
||
string name,
|
||
IReadOnlyList<object> selections,
|
||
IReadOnlyList<object>? executions = null,
|
||
IReadOnlyList<object>? hires = null)
|
||
{
|
||
var selectionList = SelectionListUrl(name);
|
||
var hireList = HireListUrl(name);
|
||
var selectionPage = $"/подбор-{name}";
|
||
var hirePage = $"/приём-{name}";
|
||
|
||
await CreateTypeAsync($"Подбор {name}", selectionList, selectionPage);
|
||
await CreateTypeAsync($"Приём {name}", hireList, hirePage);
|
||
|
||
var sharePoint = new Mock<ISharePointClient>();
|
||
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListUrlAsync(
|
||
SiteUrl, selectionList, It.Is<string>(q => q.Contains("OpenVacanciesCount")),
|
||
It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(selections.Select(Json).ToList());
|
||
|
||
sharePoint
|
||
.Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(DataListTitle);
|
||
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListTitleAsync(
|
||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("Исполнение")),
|
||
It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync((executions ?? []).Select(Json).ToList());
|
||
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListUrlAsync(
|
||
SiteUrl, hireList, It.Is<string>(q => q.Contains("SelectionRequestId")),
|
||
It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync((hires ?? []).Select(Json).ToList());
|
||
|
||
var service = new VacancyReportService(
|
||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||
sharePoint.Object,
|
||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||
Options.Create(new ReportsOptions
|
||
{
|
||
RecruitingSelectionPageUrl = selectionPage,
|
||
RecruitingHirePageUrl = hirePage
|
||
}),
|
||
NullLogger<VacancyReportService>.Instance);
|
||
|
||
return (service, sharePoint);
|
||
}
|
||
|
||
private static VacancyReportRow SingleRow(VacancyReport report) =>
|
||
report.Groups.ShouldHaveSingleItem().Rows.ShouldHaveSingleItem();
|
||
|
||
[Fact(DisplayName = "Закрытая вакансия: приём даёт число закрытых, «да» и дату выхода")]
|
||
public async Task ClosedVacancy_FilledFromHire()
|
||
{
|
||
const string name = "closed";
|
||
var (service, _) = await BuildAsync(
|
||
name,
|
||
[Selection(1, "Зам нач.юр.отдела")],
|
||
[Execution(1, date: "2026-07-08T10:00:00Z")],
|
||
[Hire(1, SelectionListUrl(name))]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
result.IsSuccess.ShouldBeTrue();
|
||
var row = SingleRow(result.Value);
|
||
row.Position.ShouldBe("Зам нач.юр.отдела");
|
||
row.Department.ShouldBe("офис");
|
||
row.VacanciesCount.ShouldBe(1);
|
||
row.ClosedCount.ShouldBe(1);
|
||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes);
|
||
row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||
row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8));
|
||
row.Initiator.ShouldBe("Ким Н.Ю.");
|
||
row.Recruiter.ShouldBe("Сергеева Надежда Ивановна");
|
||
var line = row.Lines.ShouldHaveSingleItem();
|
||
line.StartDate.ShouldBe(new DateOnly(2026, 8, 17));
|
||
line.CandidateName.ShouldBe("Шакенов Анель");
|
||
}
|
||
|
||
[Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")]
|
||
public async Task Hire_FromOtherPartition_NotMatched()
|
||
{
|
||
var (service, _) = await BuildAsync(
|
||
"otherPartition",
|
||
[Selection(1, "Зам нач.юр.отдела")],
|
||
[Execution(1)],
|
||
[Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
var row = SingleRow(result.Value);
|
||
row.ClosedCount.ShouldBe(0);
|
||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||
// Приёма нет — строка всё равно одна, но пустая (см. VacancyReportLine).
|
||
row.Lines.ShouldHaveSingleItem().StartDate.ShouldBeNull();
|
||
}
|
||
|
||
[Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")]
|
||
public async Task PartiallyClosed()
|
||
{
|
||
const string name = "partial";
|
||
var (service, _) = await BuildAsync(
|
||
name,
|
||
[Selection(1, "Логист", vacancies: 2)],
|
||
[Execution(1, status: "На исполнении", date: null)],
|
||
[Hire(1, SelectionListUrl(name))]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
var row = SingleRow(result.Value);
|
||
row.VacanciesCount.ShouldBe(2);
|
||
row.ClosedCount.ShouldBe(1);
|
||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial);
|
||
}
|
||
|
||
[Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")]
|
||
public async Task ExecutionInProgress_NoClosingDate()
|
||
{
|
||
var (service, _) = await BuildAsync(
|
||
"inprogress",
|
||
[Selection(1, "Начальник ОИД")],
|
||
[
|
||
Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1),
|
||
Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна")
|
||
]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
var row = SingleRow(result.Value);
|
||
row.ClosingDate.ShouldBeNull();
|
||
// Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он.
|
||
row.Recruiter.ShouldBe("Ким Дания Сериковна");
|
||
}
|
||
|
||
[Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")]
|
||
public async Task RejectedHire_DoesNotClose()
|
||
{
|
||
const string name = "rejected";
|
||
var (service, _) = await BuildAsync(
|
||
name,
|
||
[Selection(1, "Юрист")],
|
||
[Execution(1)],
|
||
[Hire(1, SelectionListUrl(name), status: "Отклонено")]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
var row = SingleRow(result.Value);
|
||
row.ClosedCount.ShouldBe(0);
|
||
row.ClosedFlag.ShouldBe(VacancyClosedFlags.No);
|
||
}
|
||
|
||
[Fact(DisplayName = "Несколько приёмов по вакансии — своя строка на каждого, по дате выхода")]
|
||
public async Task SeveralHires_OneLineEach()
|
||
{
|
||
const string name = "several";
|
||
var list = SelectionListUrl(name);
|
||
var (service, _) = await BuildAsync(
|
||
name,
|
||
[Selection(1, "Разнорабочий", vacancies: 2)],
|
||
[Execution(1)],
|
||
[
|
||
// Намеренно НЕ в порядке дат — проверяем сортировку строк.
|
||
Hire(1, list, hireDate: "2026-08-25T00:00:00Z", candidate: "Бутиев Азамат"),
|
||
Hire(1, list, hireDate: "2026-08-05T00:00:00Z", candidate: "Дулин Евгений")
|
||
]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
var row = SingleRow(result.Value);
|
||
row.ClosedCount.ShouldBe(2);
|
||
// Вакансия одна, но строк на листе две — по приёму на каждую.
|
||
row.Lines.Count.ShouldBe(2);
|
||
row.Lines.Select(l => 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 = "Пустая «Дата подачи заявки» — берётся дата создания элемента")]
|
||
public async Task SubmitDateEmpty_FallsBackToCreated()
|
||
{
|
||
var (service, _) = await BuildAsync(
|
||
"created",
|
||
[Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30));
|
||
}
|
||
|
||
[Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")]
|
||
public async Task VacanciesCountEmpty_IsOne()
|
||
{
|
||
var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
SingleRow(result.Value).VacanciesCount.ShouldBe(1);
|
||
}
|
||
|
||
[Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")]
|
||
public async Task Groups_UnnamedFirstThenAlphabetical()
|
||
{
|
||
var (service, _) = await BuildAsync(
|
||
"groups",
|
||
[
|
||
Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"),
|
||
Selection(2, "Логист"),
|
||
Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау")
|
||
]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]);
|
||
}
|
||
|
||
[Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")]
|
||
public async Task ReplacedEmployee_OnlyForReplacementReason()
|
||
{
|
||
var (service, _) = await BuildAsync(
|
||
"replaced",
|
||
[
|
||
Selection(1, "Начальник геодезии",
|
||
openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"),
|
||
Selection(2, "Инженер ВиВ",
|
||
openReason: "Новая штатная единица", openReasonDetails: "по расширению штата")
|
||
]);
|
||
|
||
var result = await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
// Приёмов нет ни у одной — но строка всё равно есть, и в ней виден откат
|
||
// на уточнение к причине открытия вакансии (см. VacancyReportLine).
|
||
var rows = result.Value.Groups.ShouldHaveSingleItem().Rows;
|
||
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 = "Конец периода раньше начала — ошибка, а не пустой файл")]
|
||
public async Task InvertedPeriod_Fails()
|
||
{
|
||
var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]);
|
||
|
||
var result = await service.GetAsync(To, From, CancellationToken.None);
|
||
|
||
result.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")]
|
||
public async Task PeriodFilter_PassedToSharePoint()
|
||
{
|
||
const string name = "filter";
|
||
var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]);
|
||
|
||
await service.GetAsync(From, To, CancellationToken.None);
|
||
|
||
sharePoint.Verify(
|
||
x => x.GetItemsByListUrlAsync(
|
||
SiteUrl,
|
||
SelectionListUrl(name),
|
||
It.Is<string>(q =>
|
||
q.Contains("SubmitDate ge datetime'2026-07-01T00:00:00Z'")
|
||
// Верхняя граница — начало следующего дня и строгое lt: иначе
|
||
// заявки за сам последний день периода отсеклись бы.
|
||
&& q.Contains("SubmitDate lt datetime'2026-09-01T00:00:00Z'")
|
||
&& q.Contains("SubmitDate eq null and Created ge")),
|
||
It.IsAny<CancellationToken>()),
|
||
Times.Once);
|
||
}
|
||
}
|