From 95d460c80a90abd6e9fbdc19258dd7da8cbc15be Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 11:43:45 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20reminders=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/19 --- CLAUDE.md | 4 + .../Controllers/RemindersController.cs | 9 +- backend/Tnsu.ApprovalRoute.Api/Program.cs | 1 + .../Reminders/ApprovalOverdueReminderDto.cs | 35 ++ .../Reminders/ApprovalRow.cs | 12 +- .../Reminders/EmployeeDirectorySnapshot.cs | 95 +++++ .../Reminders/HireRow.cs | 46 +++ .../Reminders/ProbationEndingReminderDto.cs | 24 ++ .../Reminders/ReminderDto.cs | 51 +-- .../Reminders/ReminderFeed.cs | 15 + .../Reminders/ReminderService.cs | 236 ++++++++++- .../Reminders/RemindersOptions.cs | 31 ++ .../TemplateRouteStepDto.cs | 4 +- .../TemplateRouteStepValidators.cs | 35 ++ .../Enums/ConditionsLogic.cs | 16 +- .../TnsuPortal/CachingTnsuPortalClient.cs | 10 + .../TnsuPortal/HttpTnsuPortalClient.cs | 30 ++ .../TnsuPortal/ITnsuPortalClient.cs | 16 + .../TnsuPortal/TnsuPortalDtos.cs | 14 + .../ApplicationServices/ReminderDtoTests.cs | 32 ++ .../ReminderServiceTests.cs | 373 +++++++++++++++++- .../TemplateRouteServiceTests.cs | 56 +-- .../TemplateRouteStepServiceTests.cs | 78 ++-- 23 files changed, 1113 insertions(+), 110 deletions(-) create mode 100644 CLAUDE.md create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a3aefb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# Стайл-гайд для Клода + +- У `enum` первое значение (0) — всегда `None`. +- Сборка не должна выдавать ни одного warning. diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs index 5e5d3e4..4d61bfe 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs @@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Api.Controllers; /// -/// Просроченные шаги согласования и исполнения на текущий момент. +/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без +/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до +/// появления ProbationEnding. /// /// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает /// Power Automate по расписанию (токен приложения), но и из админки его удобно @@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas { [HttpGet] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task List(CancellationToken ct) => - (await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult(); + public async Task List( + [FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) => + (await service.GetRemindersAsync(feed, ct)).ToActionResult(); } diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 16109f4..48a3aee 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -52,6 +52,7 @@ builder.Services.AddCors(options => .AllowAnyHeader() .AllowAnyMethod()); }); +builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs new file mode 100644 index 0000000..9ea1c62 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs @@ -0,0 +1,35 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым +/// именем ради обратной совместимости с потребителями, читавшими это поле до +/// появления общего ReceiverEmail/ReceiverName в базовом классе. +/// +/// +/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). +/// Выходные не учитываются — считаются календарные часы. +/// +/// +/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым +/// наследованием (шаг перекрывает тип заявки). +/// +/// Id типа заявки (RequestType). +/// Название типа заявки — для читаемости в письме/таблице, без похода в БД. +/// Id заявки (TicketId строки списка согласования). +/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»). +/// +/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у +/// заявки его нет или строка данных не найдена. +/// +public sealed record ApprovalOverdueReminderDto( + string Email, + int HoursOverdue, + bool AskOverdueReason, + int RequestTypeId, + string RequestTypeName, + int RequestId, + string Status, + string? RequestUniqueCode, + string Url, + string? ReceiverName = null) + : ReminderDto(Email, ReceiverName, Url); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs index cd5d224..e48365e 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs @@ -15,6 +15,7 @@ internal sealed record ApprovalRow( string Phase, DateTimeOffset? Date, string? ApproverEmail, + string? ApproverName, int? SlaHours, bool? AskOverdueReason) { @@ -23,9 +24,9 @@ internal sealed record ApprovalRow( // TicketId — Lookup на заявку; без него строку не с чем связать. if (!TryGetInt(item, "TicketId", out var ticketId)) return null; - var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object - ? GetString(a, "EMail") - : null; + var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object + ? a + : (JsonElement?)null; return new ApprovalRow( TryGetInt(item, "Id", out var id) ? id : 0, @@ -35,7 +36,10 @@ internal sealed record ApprovalRow( // трактуем как «Согласование» (так же поступает request-portal). GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval, TryGetDate(item, "Date"), - approver, + approverObject is { } ao ? GetString(ao, "EMail") : null, + // Title у персонального поля — не всегда заполнен (например, у + // системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null. + approverObject is { } an ? GetString(an, "Title") : null, TryGetInt(item, "SLAhours", out var sla) ? sla : null, TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null); } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs new file mode 100644 index 0000000..9ea516d --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs @@ -0,0 +1,95 @@ +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.Reminders; + +internal enum EmploymentStatus +{ + /// + /// Статус не определяли. EmploymentOf такого не возвращает — значение существует + /// только как значение по умолчанию (default(EmploymentStatus)); трактуется + /// наравне с , то есть «трудоустройство не подтверждено». + /// + None = 0, + + /// Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено. + NotFound = 1, + + /// Работает хотя бы в одной компании. + Employed = 2, + + /// Есть в справочнике, но уволен из всех компаний, где числился. + Dismissed = 3, +} + +/// +/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть +/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны +/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет +/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат +/// записан именно ФИО (колонка CandidateName) — временное решение, см. +/// . +/// +/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда +/// это несколько записей одного и того же человека), но с тем же успехом могут +/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя, +/// поэтому все совпадения трактуются как записи одного человека. +/// +internal sealed class EmployeeDirectorySnapshot +{ + private readonly ILookup _fullBaseByFio; + private readonly ILookup _contactsByFio; + private readonly IReadOnlyDictionary _fioByEmail; + + public EmployeeDirectorySnapshot( + IReadOnlyList fullBase, IReadOnlyList contacts) + { + _fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio)); + _contactsByFio = contacts.ToLookup(e => Normalize(e.Fio)); + + // Обратный индекс email → ФИО — нужен, чтобы подписать напоминание именем + // получателя, когда известен только его email (руководитель кандидата, + // см. GetManagerAsync). При дублях email в справочнике побеждает первая + // запись — этого достаточно для отображения имени, не для идентификации. + _fioByEmail = contacts + .Where(e => !string.IsNullOrWhiteSpace(e.Email)) + .GroupBy(e => e.Email!.Trim().ToLowerInvariant()) + .ToDictionary(g => g.Key, g => g.First().Fio); + } + + /// + /// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его + /// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в + /// другой, продолжает работать — и напоминание ему по-прежнему нужно. + /// + public EmploymentStatus EmploymentOf(string fio) + { + var entries = _fullBaseByFio[Normalize(fio)].ToList(); + if (entries.Count == 0) return EmploymentStatus.NotFound; + + return entries.All(e => e.DismissalDate is not null) + ? EmploymentStatus.Dismissed + : EmploymentStatus.Employed; + } + + /// + /// Первый непустой email среди записей этого ФИО. У одного человека записей + /// несколько (по компании на каждую), а email у него один и тот же — но + /// заполнен он может быть не в каждой записи. + /// + public string? EmailOf(string fio) => + _contactsByFio[Normalize(fio)] + .Select(e => e.Email) + .FirstOrDefault(email => !string.IsNullOrWhiteSpace(email)); + + /// ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf). + public string? NameByEmail(string email) => + _fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant()); + + /// + /// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта + /// регистра и лишних пробелов между словами (в живых данных встречаются и + /// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All). + /// + private static string Normalize(string fio) => + string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant(); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs new file mode 100644 index 0000000..20ac186 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs @@ -0,0 +1,46 @@ +using System.Text.Json; + +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Строка списка «Прием на работу — данные», разобранная из ответа SharePoint. +/// Имена колонок — как в request-portal (provisioning.json формы RecruitingHire). +/// +internal sealed record HireRow( + int Id, + string Status, + string? CandidateName, + DateTimeOffset? HireDate, + string? ProbationPeriod, + string? RequestUniqueCode) +{ + public static HireRow? TryParse(JsonElement item) + { + if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) return null; + + return new HireRow( + id, + GetString(item, "RequestStatus") ?? string.Empty, + GetString(item, "CandidateName"), + TryGetDate(item, "HireDate"), + GetString(item, "ProbationPeriod"), + GetString(item, "RequestUniqueCode")); + } + + private static string? GetString(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static DateTimeOffset? TryGetDate(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(v.GetString(), out var parsed) + ? parsed + : null; +} + +/// Статусы списка данных («… — данные», колонка RequestStatus) — считаем приём завершённым по ним. +internal static class HireRequestStatuses +{ + public const string Approved = "Согласовано"; + public const string Closed = "Закрыта"; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs new file mode 100644 index 0000000..3a810ab --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs @@ -0,0 +1,24 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Email руководителя принимаемого сотрудника — кому адресовано напоминание. +/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение +/// под вторым именем. +/// +/// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). +/// ФИО принимаемого сотрудника (колонка CandidateName списка данных). +/// Текст испытательного срока (колонка ProbationPeriod). +/// Дата приёма (колонка HireDate). +/// Дата окончания испытательного срока (HireDate + срок). +/// Сколько дней осталось до конца испытательного срока. +public sealed record ProbationEndingReminderDto( + string ReceiverEmail, + string CandidateEmail, + string CandidateName, + string? ProbationPeriod, + DateTimeOffset HireDate, + DateTimeOffset ProbationEndsAt, + int DaysUntilProbationEnds, + string Url, + string? ReceiverName = null) + : ReminderDto(ReceiverEmail, ReceiverName, Url); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs index 73f808a..3563423 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs @@ -1,27 +1,32 @@ +using System.Text.Json.Serialization; + namespace Tnsu.ApprovalRoute.Application.Reminders; -/// Согласующий/исполнитель из строки списка — кому напоминать. -/// -/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). -/// Выходные не учитываются — считаются календарные часы. +/// Email того, кому адресовано напоминание. +/// +/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего +/// в старой строке списка без заполненного Title персонального поля) — тогда null. /// -/// Ссылка на заявку, требующую решения. -/// -/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым -/// наследованием (шаг перекрывает тип заявки). +/// +/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя), +/// потому что не константна: requestId и партиция списка (server-relative url, +/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает — +/// дублировать эту логику снаружи незачем. /// -/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»). -/// -/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у -/// заявки его нет или строка данных не найдена. -/// -public sealed record ReminderDto( - string Email, - int HoursOverdue, - string Url, - bool AskOverdueReason, - int RequestTypeId, - string RequestTypeName, - int RequestId, - string Status, - string? RequestUniqueCode); +/// +/// Общая часть двух видов напоминаний — и +/// . Категории заявки (RequestTypeId, +/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования +/// (Power Automate ссылается на неё, работая со списком согласования конкретного +/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto. +/// +/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт +/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих +/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь, +/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type" +/// со значением "ApprovalOverdue"/"ProbationEnding". +/// +[JsonPolymorphic] +[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")] +[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")] +public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs new file mode 100644 index 0000000..33085df --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs @@ -0,0 +1,15 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Какой вид напоминаний запросить у . +/// +/// («вид не указан») трактуется как : +/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и +/// вызов без явного выбора обязан работать как раньше. +/// +public enum ReminderFeed +{ + None = 0, + ApprovalOverdue = 1, + ProbationEnding = 2, +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 6808b87..f38ffd4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.Reminders; @@ -22,6 +23,8 @@ public sealed class ReminderService( ApprovalRouteDbContext db, ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, + ITnsuPortalClient portalClient, + TimeProvider timeProvider, IOptions options, ILogger logger) { @@ -30,6 +33,32 @@ public sealed class ReminderService( private const string ActiveStatusFilter = $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; + /// + /// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше + /// это был единственный вид напоминаний, и без явного выбора им и остаётся. + /// + public Task>> GetRemindersAsync(CancellationToken cancellationToken) => + GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken); + + /// + /// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей + /// стороны. Момент «сейчас» берётся здесь же, из , а не + /// пробрасывается вызывающей стороной — во всех проде это TimeProvider.System + /// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/ + /// GetProbationEndingAsync по-прежнему принимают now явным параметром: так + /// их сценарии остаются детерминированными без своего TimeProvider на каждый тест. + /// + public Task>> GetRemindersAsync( + ReminderFeed feed, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + return feed switch + { + ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken), + _ => GetOverdueAsync(now, cancellationToken), + }; + } + public async Task>> GetOverdueAsync( DateTimeOffset now, CancellationToken cancellationToken) { @@ -176,9 +205,17 @@ public sealed class ReminderService( // ДО появления колонки (см. ApprovalRow) или если request-portal // почему-то не проставил её при активации. Без даты старта SLA не // от чего отсчитывать — пропускаем, а не подставляем сейчас. + // + // RequestTypeId — всегда, чтобы искать TicketId в правильном типе + // заявок. RequestUniqueCode — то, по чему заявку реально ищут в + // портале, добавляется, только если он уже есть (строка данных + // могла ещё не появиться или заявка старше появления колонки). + var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code + ? $"requestTypeId={type.Id}, code={code}" + : $"requestTypeId={type.Id}"; logger.LogWarning( - "Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена", - row.Id, row.TicketId); + "Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена", + row.Id, row.TicketId, identifier); continue; } @@ -188,16 +225,17 @@ public sealed class ReminderService( var deadline = activatedAt.AddHours(slaHours); if (deadline >= now) continue; - reminders.Add(new ReminderDto( + reminders.Add(new ApprovalOverdueReminderDto( row.ApproverEmail!, (int)Math.Floor((now - deadline).TotalHours), - BuildUrl(type, row, listId), row.AskOverdueReason ?? type.AskOverdueReason ?? false, type.Id, type.Name, row.TicketId, row.Status, - uniqueCodes.GetValueOrDefault(row.TicketId))); + uniqueCodes.GetValueOrDefault(row.TicketId), + BuildUrl(type, row, listId), + row.ApproverName)); } return reminders; @@ -234,7 +272,7 @@ public sealed class ReminderService( private const string RowSelect = "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," + - "TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000"; + "TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000"; private async Task> ReadRowsAsync( string siteUrl, string listTitle, string filter, CancellationToken cancellationToken) @@ -273,4 +311,190 @@ public sealed class ReminderService( return result; } + + /// + /// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока. + /// + /// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую + /// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам, + /// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в + /// портале, и напоминание уходило бы на день раньше срока. + /// + /// Константой, а не настройкой: смещение фиксировано для всей организации и не + /// меняется от окружения (перехода на летнее время нет). + /// + private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6); + + private static readonly IReadOnlyDictionary ProbationMonthsByOption = + new Dictionary + { + ["1 месяц"] = 1, + ["2 месяца"] = 2, + ["3 месяца"] = 3, + ["6 месяцев"] = 6, + }; + + /// + /// Кому скоро выходить из испытательного срока — по заявкам «Заявка на приём» + /// (recruiting-hire). В отличие от НЕ завязано на + /// SlaHours типа заявки: это отдельный вид напоминаний со своим триггером + /// (дата приёма + испытательный срок), а не просрочка шага согласования. + /// + public async Task>> GetProbationEndingAsync( + DateTimeOffset now, CancellationToken cancellationToken) + { + var type = await db.RequestTypes.AsNoTracking() + .FirstOrDefaultAsync( + x => x.PageUrl == options.Value.RecruitingHirePageUrl && x.ListId != null && x.SiteUrl != null, + cancellationToken); + + if (type is null) return Result.Ok>([]); + + var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); + var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); + + // Испытательный срок — не больше 6 месяцев (см. ProbationMonthsByOption), + // поэтому заявка, способная дать напоминание сегодня, обязана лежать в + // текущей партиции или в партиции прямо перед ней: более старая партиция + // физически не может содержать ещё актуальный испытательный срок. Читать + // весь архив, как это делает GetOverdueAsync (там заявка может висеть + // непринятой годами), здесь незачем — только лишняя нагрузка на SharePoint. + var lastArchived = archived.OrderByDescending(x => x.Number).FirstOrDefault(); + var partitions = new List<(string ListId, int Number)> { (current.ListId, current.Number) }; + if (lastArchived is not null) partitions.Add((lastArchived.ListId, lastArchived.Number)); + + // Обе базы сотрудников читаются по одному разу на весь обход партиций (а не + // на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны + // сразу и дата увольнения (только в FullBase), и email (только в All). + var directory = new EmployeeDirectorySnapshot( + await portalClient.GetFullBaseAsync(cancellationToken), + await portalClient.GetAllEmployeesAsync(cancellationToken)); + + var reminders = new List(); + foreach (var (listId, number) in partitions) + { + try + { + reminders.AddRange( + await GetProbationEndingForPartitionAsync(type, listId, directory, now, cancellationToken)); + } + catch (Exception e) + { + logger.LogError( + e, "Напоминания об испытательном сроке по списку {ListId} (партиция №{Number}) не собраны", + listId, number); + } + } + + return Result.Ok>(reminders); + } + + private const string HireRowSelect = + "$select=Id,RequestStatus,CandidateName,HireDate,ProbationPeriod,RequestUniqueCode&$top=5000"; + + private async Task> GetProbationEndingForPartitionAsync( + RequestType type, string listId, EmployeeDirectorySnapshot directory, + DateTimeOffset now, CancellationToken cancellationToken) + { + var siteUrl = type.SiteUrl!; + + var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); + if (string.IsNullOrWhiteSpace(dataListTitle)) return []; + + // HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две + // последних партиции (см. GetProbationEndingAsync): более старый приём уже + // не может дать напоминание, читать его строки незачем даже в пределах + // партиции, где они могут лежать вперемешку со свежими. + var since = now.AddMonths(-6); + var filter = + $"$filter=(RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}')" + + $" and HireDate ge datetime'{since:yyyy-MM-ddTHH:mm:ssZ}'"; + var items = await sharePoint.GetItemsByListTitleAsync( + siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken); + var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); + + var reminders = new List(); + foreach (var row in rows) + { + var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken); + if (reminder is not null) reminders.Add(reminder); + } + + return reminders; + } + + /// + /// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний — + /// константа из настроек (см. ProbationReminderUrl), а поля самой заявки + /// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят. + /// + private async Task TryBuildProbationReminderAsync( + HireRow row, EmployeeDirectorySnapshot directory, + DateTimeOffset now, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null; + + if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months)) + return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего. + + // Обе даты — в местном поясе (см. LocalOffset): и дату приёма, и «сегодня» + // надо брать по тому же календарю, который видит человек в портале. + var hireDateLocal = hireDate.ToOffset(LocalOffset); + var probationEndsAt = hireDateLocal.AddMonths(months); + var daysLeft = (probationEndsAt.Date - now.ToOffset(LocalOffset).Date).Days; + if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null; + + var candidateName = row.CandidateName.Trim(); + + // Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default: + // так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None, + // если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не + // молча считается «работает». + switch (directory.EmploymentOf(candidateName)) + { + case EmploymentStatus.Employed: + break; + case EmploymentStatus.Dismissed: + return null; // уже уволен — напоминать не о чем. + default: + logger.LogWarning( + "Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено", + row.Id, candidateName); + return null; + } + + var candidateEmail = directory.EmailOf(candidateName); + if (string.IsNullOrWhiteSpace(candidateEmail)) + { + logger.LogWarning( + "Заявка на приём {RequestId}: у кандидата «{Fio}» не найден email в справочнике — напоминание пропущено", + row.Id, candidateName); + return null; + } + + var managerEmail = await portalClient.GetManagerAsync(candidateEmail, "chief", cancellationToken); + if (string.IsNullOrWhiteSpace(managerEmail)) + { + logger.LogWarning( + "Заявка на приём {RequestId}: у кандидата «{Fio}» не найден руководитель — напоминание пропущено", + row.Id, candidateName); + return null; + } + + return new ProbationEndingReminderDto( + ReceiverEmail: managerEmail, + CandidateEmail: candidateEmail, + CandidateName: candidateName, + ProbationPeriod: row.ProbationPeriod, + // Тоже в местном поясе — иначе в письме дата приёма была бы на сутки + // раньше той, от которой посчитан ProbationEndsAt рядом. + HireDate: hireDateLocal, + ProbationEndsAt: probationEndsAt, + DaysUntilProbationEnds: daysLeft, + // Не ссылка на саму заявку на приём (она уже закрыта, решать по ней + // нечего) — константный адрес раздела, где руководитель заводит новую + // заявку по итогам испытательного срока. См. ProbationReminderUrl. + Url: options.Value.ProbationReminderUrl, + ReceiverName: directory.NameByEmail(managerEmail)); + } } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs index 2776b5e..51352da 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs @@ -24,4 +24,35 @@ public sealed class RemindersOptions /// public string RequestUrlTemplate { get; set; } = "{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}"; + + /// + /// PageUrl типа заявки «Заявка на приём» (см. request-portal, forms/RecruitingHire/model.ts, + /// FORM_ID) — по нему ищется RequestType для напоминаний об окончании испытательного срока. + /// Не завязано на SlaHours: этот вид напоминаний не про просрочку шага согласования. + /// + public string RecruitingHirePageUrl { get; set; } = "recruiting-hire"; + + /// + /// За сколько дней до конца испытательного срока присылать напоминание — неделя, + /// чтобы у руководителя было время подготовить решение по итогам срока. + /// + /// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до + /// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной. + /// + public int ProbationReminderDaysBeforeEnd { get; set; } = 7; + + /// + /// Куда ведёт ссылка в напоминании об окончании испытательного срока — на + /// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от + /// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение + /// по итогам испытательного срока), а не открыть уже закрытую старую. + /// + /// Поэтому ссылка константная, без подстановок: адрес конкретной формы в + /// портал вообще не передать — параметр rptype без rpid/rpsource + /// игнорируется, и маршрут сваливается в раздел «Подать заявку» (см. + /// parseRoute в request-portal/shared/routing.ts). Отдельная настройка, а не + /// хардкод, — чтобы адрес правился конфигурацией. + /// + public string ProbationReminderUrl { get; set; } = + "https://tnsukz.sharepoint.com/sites/requests?rpnav=new"; } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 9b008b3..eb41027 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -35,7 +35,7 @@ public sealed record CreateTemplateRouteStepRequest( RouteStepStatus Status, bool AllowReassignToOtherApprovers, IReadOnlyList? Conditions, - ConditionsLogic ConditionsLogic = ConditionsLogic.And, + ConditionsLogic ConditionsLogic, int? SlaHours = null, bool? AskOverdueReason = null); @@ -48,7 +48,7 @@ public sealed record UpdateTemplateRouteStepRequest( RouteStepStatus Status, bool AllowReassignToOtherApprovers, IReadOnlyList? Conditions, - ConditionsLogic ConditionsLogic = ConditionsLogic.And, + ConditionsLogic ConditionsLogic, int? SlaHours = null, bool? AskOverdueReason = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 2a5d42e..e9c5ab7 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -8,6 +8,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< public CreateTemplateRouteStepRequestValidator() { RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); + RuleFor(x => x.ConditionsLogic) + .NotEqual(ConditionsLogic.None) + .WithMessage("Логика объединения условий обязательна: And или Or."); RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); @@ -28,6 +31,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< public UpdateTemplateRouteStepRequestValidator() { RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); + RuleFor(x => x.ConditionsLogic) + .NotEqual(ConditionsLogic.None) + .WithMessage("Логика объединения условий обязательна: And или Or."); RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); @@ -43,6 +49,35 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< } } +/// +/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно: +/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО +/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest). +/// +public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator +{ + public PatchTemplateRouteStepRequestValidator() + { + When(x => x.Status is not null, () => + RuleFor(x => x.Status) + .NotEqual(RouteStepStatus.None) + .WithMessage("Статус обязателен: Approval или Execution.")); + When(x => x.ConditionsLogic is not null, () => + RuleFor(x => x.ConditionsLogic) + .NotEqual(ConditionsLogic.None) + .WithMessage("Логика объединения условий обязательна: And или Or.")); + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions)) + .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны."); + // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; + // ноль допустим — это осознанное «напоминать сразу». + RuleFor(x => x.SlaHours) + .GreaterThanOrEqualTo(0) + .When(x => x.SlaHours is not null) + .WithMessage("SLA в часах не может быть отрицательным."); + } +} + internal static class TemplateRouteStepFields { public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) => diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs index 1f37402..a0ba7f3 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs @@ -6,9 +6,21 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; /// public enum ConditionsLogic { + /// + /// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И». + /// Существует только как значение по умолчанию (default(ConditionsLogic)): + /// шаг с такой логикой не должен ни создаваться, ни сохраняться. + /// + /// Именно поэтому у самой сущности и у запросов на создание/обновление + /// значение по умолчанию задано явно — ConditionsLogic.And + /// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest): + /// пропущенное в запросе поле по-прежнему означает «И», как и раньше. + /// + None = 0, + /// Шаг появляется, только если выполняются ВСЕ условия. - And, + And = 1, /// Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие. - Or + Or = 2 } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 6f1ade9..4b931e2 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -27,11 +27,21 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC $"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}", () => inner.GetEmployeeByPositionAsync(position, company, cancellationToken)); + public Task> GetAllEmployeesAsync(CancellationToken cancellationToken) => + GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken)); + public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => GetOrAddAsync( $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", () => inner.GetManagerAsync(email, managerType, cancellationToken)); + // Базы целиком (/Employee/All выше и /Employee/FullBase ниже) кешируем по + // фиксированному ключу — это не запросы «по чему-то», а единственные на весь + // процесс снимки справочника; TTL тот же (5 минут), что и у остальных + // обращений к api.tnsu.kz. + public Task> GetFullBaseAsync(CancellationToken cancellationToken) => + GetOrAddAsync("emp:fullbase", () => inner.GetFullBaseAsync(cancellationToken)); + public Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) => GetOrAddAsync( $"projexec:{projectId}", diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index c679c13..4a56e4e 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -32,6 +32,18 @@ public sealed class HttpTnsuPortalClient( return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio); } + public async Task> GetAllEmployeesAsync(CancellationToken cancellationToken) + { + var employees = await GetAsync>("/Employee/All", cancellationToken); + if (employees is null) + return []; + + return employees + .Where(e => !string.IsNullOrWhiteSpace(e.Fio)) + .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty)) + .ToList(); + } + public async Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) { var manager = await GetAsync( @@ -40,6 +52,18 @@ public sealed class HttpTnsuPortalClient( return manager?.ManagerEmail; } + public async Task> GetFullBaseAsync(CancellationToken cancellationToken) + { + var employees = await GetAsync>("/Employee/FullBase", cancellationToken); + if (employees is null) + return []; + + return employees + .Where(e => !string.IsNullOrWhiteSpace(e.Fio)) + .Select(e => new EmployeeFullBaseInfo(e.Id, e.Fio!, e.Company ?? string.Empty, e.DismissalDate)) + .ToList(); + } + public async Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) { var project = await GetAsync($"/Project/{projectId}", cancellationToken); @@ -100,6 +124,12 @@ public sealed class HttpTnsuPortalClient( private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); + private sealed record FullBaseResponse( + [property: JsonPropertyName("id")] Guid? Id, + [property: JsonPropertyName("fio")] string? Fio, + [property: JsonPropertyName("company")] string? Company, + [property: JsonPropertyName("dismissalDate")] DateTimeOffset? DismissalDate); + private sealed record ProjectResponse( [property: JsonPropertyName("projectManager")] string? ProjectManager, [property: JsonPropertyName("planner")] string? Planner, diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 731a898..4da2118 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -11,8 +11,24 @@ public interface ITnsuPortalClient Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); + /// + /// Весь справочник сотрудников с email — /Employee/All. Нужен там, где известно + /// только ФИО, а email ещё нет: точечного поиска по ФИО в api.tnsu.kz нет вовсе + /// (эндпоинта /Employee/ByFio не существует), поэтому приходится тянуть базу + /// целиком и искать в ней локально. ВРЕМЕННОЕ решение — как только на стороне + /// api.tnsu.kz появится поиск по ФИО, этот вызов заменится точечным. + /// + Task> GetAllEmployeesAsync(CancellationToken cancellationToken); + Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); + /// + /// Весь справочник сотрудников — /Employee/FullBase. Единственный источник, где + /// есть дата увольнения (DismissalDate): нужен, чтобы не напоминать про + /// испытательный срок сотрудника, который уже уволен. + /// + Task> GetFullBaseAsync(CancellationToken cancellationToken); + Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); /// Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден. diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index bae25ac..7940891 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -17,3 +17,17 @@ public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); /// Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы). public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio); + +/// +/// Запись из /Employee/FullBase — весь справочник сотрудников, но БЕЗ email (в +/// отличие от /Employee/All). Нужна только ради DismissalDate: единственное место, +/// где отдаётся дата увольнения. +/// +public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, DateTimeOffset? DismissalDate); + +/// +/// Запись из /Employee/All — весь справочник сотрудников с email, но БЕЗ даты +/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно +/// по — он один и тот же в обоих ответах. +/// +public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs new file mode 100644 index 0000000..8b6369b --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs @@ -0,0 +1,32 @@ +using Shouldly; +using Tnsu.ApprovalRoute.Application.Reminders; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +/// +/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами +/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и +/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см. +/// ReminderDto) — так переход на новый контракт можно делать постепенно, не +/// ломая существующие Power Automate-флоу разом. +/// +public sealed class ReminderDtoTests +{ + [Fact(DisplayName = "ApprovalOverdueReminderDto: Email и унаследованный ReceiverEmail — одно и то же значение")] + public void ApprovalOverdue_EmailAndReceiverEmail_AreTheSameValue() + { + var dto = new ApprovalOverdueReminderDto( + Email: "approver@tnsu.kz", + HoursOverdue: 5, + AskOverdueReason: false, + RequestTypeId: 1, + RequestTypeName: "Тип", + RequestId: 10, + Status: "На согласовании", + RequestUniqueCode: "code", + Url: "https://tnsukz.sharepoint.com/sites/requests"); + + dto.Email.ShouldBe("approver@tnsu.kz"); + dto.ReceiverEmail.ShouldBe(dto.Email); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 778b2e9..e1f4d0a 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -9,6 +10,7 @@ using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; @@ -29,8 +31,28 @@ public sealed class ReminderServiceTests : IClassFixture now; + } + + private static readonly TimeProvider Clock = new FixedTimeProvider(Now); + private readonly ServiceProvider _services; private readonly RequestTypeService _requestTypeService; @@ -53,6 +75,23 @@ public sealed class ReminderServiceTests : IClassFixture CreateHireTypeAsync(string name) + { + var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}"; + var pageUrl = $"/{name.Replace(" ", string.Empty)}"; + await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"), + CancellationToken.None); + + return (listUrl, pageUrl); + } + private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); private static object Row( @@ -104,6 +143,8 @@ public sealed class ReminderServiceTests : IClassFixture(), sharePoint.Object, _services.GetRequiredService(), + Mock.Of(), + Clock, Options.Create(new RemindersOptions()), NullLogger.Instance); } @@ -119,12 +160,38 @@ public sealed class ReminderServiceTests : IClassFixture(); reminder.Email.ShouldBe("approver@tnsu.kz"); reminder.HoursOverdue.ShouldBe(24); reminder.RequestId.ShouldBe(1); } + [Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")] + public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue() + { + var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24); + var service = BuildService(listUrl, + [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]); + + var result = await service.GetRemindersAsync(CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldHaveSingleItem().ShouldBeOfType().HoursOverdue.ShouldBe(24); + } + + [Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")] + public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue() + { + var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24); + var service = BuildService(listUrl, + [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]); + + var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } + [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] public async Task WithinSla_ReturnsNothing() { @@ -153,7 +220,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(2); } [Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")] @@ -179,7 +246,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5); } // SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом @@ -199,7 +266,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5); } [Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")] @@ -211,7 +278,7 @@ public sealed class ReminderServiceTests : IClassFixture().AskOverdueReason.ShouldBeTrue(); } // Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён @@ -256,6 +323,300 @@ public sealed class ReminderServiceTests : IClassFixture().Email.ShouldBe("approver@tnsu.kz"); + } + + private static object HireDataRow( + int id, string status, string? candidateName, string? hireDate, string? probationPeriod, string? code = null) => + new + { + Id = id, + RequestStatus = status, + CandidateName = candidateName, + HireDate = hireDate, + ProbationPeriod = probationPeriod, + RequestUniqueCode = code + }; + + private ReminderService BuildProbationService( + string pageUrl, string listUrl, IReadOnlyList rows, Mock portalClient, + int daysBeforeEnd = ProbationDaysBeforeEnd) + { + const string hireDataListTitle = "Прием на работу тест — данные"; + + var sharePoint = new Mock(); + sharePoint + .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) + .ReturnsAsync(hireDataListTitle); + + // GetProbationEndingAsync читает только «Согласовано»/«Закрыта» — SharePoint + // фильтрует это сам ($filter в запросе), мок повторяет ту же фильтрацию. + var closedRows = rows + .Where(r => (string)r.GetType().GetProperty("RequestStatus")!.GetValue(r)! is "Согласовано" or "Закрыта") + .ToList(); + sharePoint + .Setup(x => x.GetItemsByListTitleAsync( + SiteUrl, hireDataListTitle, It.Is(q => q.Contains("RequestStatus eq")), It.IsAny())) + .ReturnsAsync(closedRows.Select(Json).ToList()); + + return new ReminderService( + _services.GetRequiredService(), + sharePoint.Object, + _services.GetRequiredService(), + portalClient.Object, + Clock, + Options.Create(new RemindersOptions + { + RecruitingHirePageUrl = pageUrl, + ProbationReminderDaysBeforeEnd = daysBeforeEnd, + ProbationReminderUrl = ProbationReminderUrl + }), + NullLogger.Instance); + } + + private static Mock BuildPortalClientMock( + string candidateFio, DateTimeOffset? dismissalDate, string? candidateEmail, string? managerEmail) + { + var portalClient = new Mock(); + + portalClient + .Setup(x => x.GetFullBaseAsync(It.IsAny())) + .ReturnsAsync([new EmployeeFullBaseInfo(Guid.NewGuid(), candidateFio, "ТОО TANSU", dismissalDate)]); + + portalClient + .Setup(x => x.GetAllEmployeesAsync(It.IsAny())) + .ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), candidateFio, candidateEmail, "ТОО TANSU")]); + + if (candidateEmail is not null) + portalClient + .Setup(x => x.GetManagerAsync(candidateEmail, "chief", It.IsAny())) + .ReturnsAsync(managerEmail); + + return portalClient; + } + + [Fact(DisplayName = "До конца испытательного срока 3 дня — напоминание с email кандидата и его руководителя")] + public async Task ProbationEndingIn3Days_BuildsReminder() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation due"); + // probationEndsAt = Now.Date + 3 дня = 2026-08-15; "2 месяца" от 2026-06-15. + var rows = new[] { HireDataRow(1, "Закрыта", "Иванов Иван Иванович", "2026-06-15T00:00:00Z", "2 месяца") }; + var portalClient = BuildPortalClientMock( + "Иванов Иван Иванович", dismissalDate: null, candidateEmail: "candidate@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType(); + reminder.ReceiverEmail.ShouldBe("manager@tnsu.kz"); + reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); + reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); + reminder.DaysUntilProbationEnds.ShouldBe(3); + // Ссылка ведёт не на саму заявку на приём, а на раздел, где заводят новую + // (константа из настроек, см. RemindersOptions.ProbationReminderUrl). + reminder.Url.ShouldBe(ProbationReminderUrl); + } + + [Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")] + public async Task DismissedCandidate_IsSkipped() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation dismissed"); + var rows = new[] { HireDataRow(1, "Закрыта", "Петров Пётр Петрович", "2026-06-15T00:00:00Z", "2 месяца") }; + var portalClient = BuildPortalClientMock( + "Петров Пётр Петрович", dismissalDate: Now.AddDays(-1), candidateEmail: "petrov@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.Value.ShouldBeEmpty(); + } + + // Сотрудник холдинга может числиться в нескольких компаниях сразу (см. + // /Employee/FullBase — по записи на компанию). Увольнение из одной из них не + // означает, что человек ушёл: пока хоть где-то дата увольнения не стоит, он + // работает, и напоминание ему по-прежнему нужно. + [Fact(DisplayName = "Уволен из одной компании, но работает в другой — напоминание есть")] + public async Task DismissedFromOneCompanyOnly_StillBuildsReminder() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation partly dismissed"); + const string fio = "Романов Роман Романович"; + var rows = new[] { HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца") }; + + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetFullBaseAsync(It.IsAny())) + .ReturnsAsync( + [ + new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО АСТЕЛ-К", Now.AddDays(-10)), + new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО TANSU Construction", null) + ]); + portalClient + .Setup(x => x.GetAllEmployeesAsync(It.IsAny())) + .ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), fio, "romanov@tnsu.kz", "ТОО TANSU Construction")]); + portalClient + .Setup(x => x.GetManagerAsync("romanov@tnsu.kz", "chief", It.IsAny())) + .ReturnsAsync("manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().ShouldBeOfType().CandidateEmail.ShouldBe("romanov@tnsu.kz"); + } + + // ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны + // ломать сопоставление со справочником (см. EmployeeDirectorySnapshot.Normalize). + [Fact(DisplayName = "ФИО сопоставляется без учёта регистра и лишних пробелов")] + public async Task CandidateName_IsMatchedLoosely() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation fio normalize"); + var rows = new[] { HireDataRow(1, "Закрыта", " волков ВОЛК волкович ", "2026-06-15T00:00:00Z", "2 месяца") }; + var portalClient = BuildPortalClientMock( + "Волков Волк Волкович", dismissalDate: null, candidateEmail: "volkov@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().ShouldBeOfType().CandidateEmail.ShouldBe("volkov@tnsu.kz"); + } + + [Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")] + public async Task CandidateNotInFullBase_IsSkipped() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation unknown"); + var rows = new[] { HireDataRow(1, "Закрыта", "Сидоров Сидор Сидорович", "2026-06-15T00:00:00Z", "2 месяца") }; + var portalClient = new Mock(); + portalClient.Setup(x => x.GetFullBaseAsync(It.IsAny())).ReturnsAsync([]); + portalClient.Setup(x => x.GetAllEmployeesAsync(It.IsAny())).ReturnsAsync([]); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.Value.ShouldBeEmpty(); + } + + [Fact(DisplayName = "До конца испытательного срока не ровно 3 дня — напоминания нет")] + public async Task NotExactlyThreeDaysLeft_IsSkipped() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation not due"); + // "1 месяц" от 2026-06-15 → срок кончился 2026-07-15, задолго до Now. + var rows = new[] { HireDataRow(1, "Закрыта", "Кузнецов Кузьма Кузьмич", "2026-06-15T00:00:00Z", "1 месяц") }; + var portalClient = BuildPortalClientMock( + "Кузнецов Кузьма Кузьмич", dismissalDate: null, candidateEmail: "kuznecov@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.Value.ShouldBeEmpty(); + } + + [Fact(DisplayName = "«Без испытательного срока» — напоминания нет")] + public async Task NoProbationPeriod_IsSkipped() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation none"); + var rows = new[] + { + HireDataRow(1, "Закрыта", "Смирнов Смирн Смирнович", "2026-06-15T00:00:00Z", "Без испытательного срока") + }; + var portalClient = BuildPortalClientMock( + "Смирнов Смирн Смирнович", dismissalDate: null, candidateEmail: "smirnov@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.Value.ShouldBeEmpty(); + } + + // Портал пишет дату приёма как МЕСТНУЮ полночь, переведённую в UTC, поэтому в + // списке она лежит как 19:00Z предыдущего дня. Здесь 2026-06-14T19:00Z — это + // 15 июня по местному календарю (см. LocalOffset в ReminderService): срок + // истекает 15 августа, до него от Now (12 августа) ровно 3 дня. + // Если считать по UTC, дата приёма превратилась бы в 14 июня, срок — в 14 + // августа, и осталось бы 2 дня: напоминание не построилось бы вовсе. + [Fact(DisplayName = "Дата приёма считается по местному календарю, а не по UTC")] + public async Task HireDate_IsReadInLocalTimeZone() + { + var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation timezone"); + var rows = new[] { HireDataRow(1, "Закрыта", "Тимуров Тимур Тимурович", "2026-06-14T19:00:00Z", "2 месяца") }; + var portalClient = BuildPortalClientMock( + "Тимуров Тимур Тимурович", dismissalDate: null, candidateEmail: "timurov@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType(); + reminder.DaysUntilProbationEnds.ShouldBe(3); + // И дата приёма, и конец срока отдаются в том же местном календаре. + reminder.HireDate.Date.ShouldBe(new DateTime(2026, 6, 15)); + reminder.ProbationEndsAt.Date.ShouldBe(new DateTime(2026, 8, 15)); + } + + // Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption + // в ReminderService), поэтому заявка, способная дать напоминание сегодня, + // физически не может лежать раньше предпоследней партиции. Три партиции: + // самая старая (№1) содержит строку-ловушку — если сервис её прочитает, тест + // это увидит по лишнему напоминанию; предпоследняя (№2) и текущая (№3) + // читаются как обычно. + [Fact(DisplayName = "Читаются только текущая и последняя архивная партиция — более старые игнорируются")] + public async Task OnlyLastTwoPartitions_AreRead() + { + var (listUrl1, pageUrl) = await CreateHireTypeAsync("Probation partitions"); + var db = _services.GetRequiredService(); + var partitionRegistry = _services.GetRequiredService(); + var type = await db.RequestTypes.FirstAsync(x => x.PageUrl == pageUrl); + + // Партиция №1 — уедет в архив первой, её строки читаться не должны. + await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None); + var listUrl2 = listUrl1 + "-2"; + type.ListId = listUrl2; + await db.SaveChangesAsync(); + + // Партиция №2 — последняя архивная, должна читаться наравне с текущей. + await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None); + var listUrl3 = listUrl1 + "-3"; + type.ListId = listUrl3; + await db.SaveChangesAsync(); + + const string fio = "Партиция Тест Тестович"; + var trapRow = HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца"); + var realRow = HireDataRow(2, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца"); + + var sharePoint = new Mock(); + sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny())).ReturnsAsync("list1 — данные"); + sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny())).ReturnsAsync("list2 — данные"); + sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny())).ReturnsAsync("list3 — данные"); + sharePoint + .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny(), It.IsAny())) + .ReturnsAsync([Json(trapRow)]); + sharePoint + .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny(), It.IsAny())) + .ReturnsAsync([Json(realRow)]); + sharePoint + .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + var portalClient = BuildPortalClientMock( + fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz"); + + var service = new ReminderService( + db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock, + // Порог задан явно (как и в BuildProbationService): тест про партиции, а + // не про то, сколько дней до конца срока настроено в проде. + Options.Create(new RemindersOptions + { + RecruitingHirePageUrl = pageUrl, + ProbationReminderUrl = ProbationReminderUrl, + ProbationReminderDaysBeforeEnd = ProbationDaysBeforeEnd + }), + NullLogger.Instance); + + var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldHaveSingleItem().ShouldBeOfType() + .CandidateEmail.ShouldBe("partition@tnsu.kz"); + sharePoint.Verify( + x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny(), It.IsAny()), + Times.Never); } } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 1f8b1a6..b3c3857 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action @@ -206,7 +206,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action — departmentId в запросе не передан @@ -419,7 +419,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And), CancellationToken.None); // Action @@ -515,11 +515,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action @@ -546,7 +546,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And), CancellationToken.None); // Action — fieldValues не переданы вовсе @@ -571,7 +571,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }), + new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }, ConditionsLogic.And), CancellationToken.None); // Action @@ -605,7 +605,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }), + new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }, ConditionsLogic.And), CancellationToken.None); // Action @@ -672,7 +672,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action @@ -710,7 +710,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action @@ -1065,7 +1065,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action @@ -1094,7 +1094,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index a6aa592..831e076 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture