From 874e41990c5b0f532bca460297163d7adad2eb9c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 26 Aug 2026 17:17:47 +0500 Subject: [PATCH 1/8] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/RemindersController.cs | 2 +- .../Reminders/EmployeeDirectorySnapshot.cs | 74 ++++++ .../Reminders/HireRow.cs | 46 ++++ .../Reminders/ReminderCategory.cs | 23 ++ .../Reminders/ReminderDto.cs | 27 ++- .../Reminders/ReminderService.cs | 177 ++++++++++++++- .../Reminders/RemindersOptions.cs | 10 + .../TnsuPortal/CachingTnsuPortalClient.cs | 10 + .../TnsuPortal/HttpTnsuPortalClient.cs | 30 +++ .../TnsuPortal/ITnsuPortalClient.cs | 16 ++ .../TnsuPortal/TnsuPortalDtos.cs | 14 ++ .../ReminderServiceTests.cs | 214 ++++++++++++++++++ 12 files changed, 640 insertions(+), 3 deletions(-) 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/ReminderCategory.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs index 5e5d3e4..584abb8 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs @@ -22,5 +22,5 @@ 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(); + (await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult(); } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs new file mode 100644 index 0000000..64b8cf0 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs @@ -0,0 +1,74 @@ +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.Reminders; + +internal enum EmploymentStatus +{ + /// Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено. + NotFound, + + /// Работает хотя бы в одной компании. + Employed, + + /// Есть в справочнике, но уволен из всех компаний, где числился. + Dismissed, +} + +/// +/// Оба справочника 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; + + public EmployeeDirectorySnapshot( + IReadOnlyList fullBase, IReadOnlyList contacts) + { + _fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio)); + _contactsByFio = contacts.ToLookup(e => Normalize(e.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)); + + /// + /// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта + /// регистра и лишних пробелов между словами (в живых данных встречаются и + /// двойные пробелы, и неразрывный пробел — см. поле 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/ReminderCategory.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs new file mode 100644 index 0000000..15faba3 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs @@ -0,0 +1,23 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Категория уведомления в . Добавлена позже самого DTO, +/// поэтому в нём параметр необязательный со значением — +/// «категория не указана»: ровно то, что было до появления этого поля, когда +/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate) +/// ни о каких категориях не знали. +/// +/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs), +/// так что числовые значения — деталь реализации, менять их порядок безопасно. +/// +public enum ReminderCategory +{ + /// Категория не указана — значение по умолчанию для обратной совместимости. + None = 0, + + /// Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync). + ApprovalOverdue = 1, + + /// Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync). + ProbationEnding = 2, +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs index 73f808a..b4ecc66 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs @@ -15,6 +15,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; /// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у /// заявки его нет или строка данных не найдена. /// +/// +/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по +/// умолчанию («не указана»): старые вызовы +/// конструктора и потребители, ничего не знающие о категориях, продолжают работать +/// как раньше. +/// +/// +/// Только для — email руководителя +/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для +/// остальных категорий — null. +/// +/// Только для ProbationEnding — email принимаемого сотрудника. +/// Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName). +/// Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod). +/// Только для ProbationEnding — дата приёма (колонка HireDate). +/// Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок). +/// Только для ProbationEnding — сколько дней осталось до конца испытательного срока. public sealed record ReminderDto( string Email, int HoursOverdue, @@ -24,4 +41,12 @@ public sealed record ReminderDto( string RequestTypeName, int RequestId, string Status, - string? RequestUniqueCode); + string? RequestUniqueCode, + ReminderCategory Category = ReminderCategory.None, + string? ManagerEmail = null, + string? CandidateEmail = null, + string? CandidateName = null, + string? ProbationPeriod = null, + DateTimeOffset? HireDate = null, + DateTimeOffset? ProbationEndsAt = null, + int? DaysUntilProbationEnds = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 6808b87..032f864 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,7 @@ public sealed class ReminderService( ApprovalRouteDbContext db, ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, + ITnsuPortalClient portalClient, IOptions options, ILogger logger) { @@ -30,6 +32,19 @@ public sealed class ReminderService( private const string ActiveStatusFilter = $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; + /// Оба вида напоминаний разом — то, что реально отдаёт контроллер. + public async Task>> GetRemindersAsync( + DateTimeOffset now, CancellationToken cancellationToken) + { + var overdue = await GetOverdueAsync(now, cancellationToken); + if (overdue.IsFailed) return overdue; + + var probation = await GetProbationEndingAsync(now, cancellationToken); + if (probation.IsFailed) return probation; + + return Result.Ok>(overdue.Value.Concat(probation.Value).ToList()); + } + public async Task>> GetOverdueAsync( DateTimeOffset now, CancellationToken cancellationToken) { @@ -197,7 +212,8 @@ public sealed class ReminderService( type.Name, row.TicketId, row.Status, - uniqueCodes.GetValueOrDefault(row.TicketId))); + uniqueCodes.GetValueOrDefault(row.TicketId), + ReminderCategory.ApprovalOverdue)); } return reminders; @@ -273,4 +289,163 @@ public sealed class ReminderService( return result; } + + 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); + var partitions = archived.Select(x => (x.ListId, x.Number)).Append((current.ListId, current.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 []; + + var filter = + $"$filter=RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}'"; + 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(type, row, listId, directory, now, cancellationToken); + if (reminder is not null) reminders.Add(reminder); + } + + return reminders; + } + + private async Task TryBuildProbationReminderAsync( + RequestType type, HireRow row, string listId, 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; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего. + + var probationEndsAt = hireDate.AddMonths(months); + var daysLeft = (probationEndsAt.Date - now.Date).Days; + if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null; + + var candidateName = row.CandidateName.Trim(); + + switch (directory.EmploymentOf(candidateName)) + { + case EmploymentStatus.Dismissed: + return null; // уже уволен — напоминать не о чем. + case EmploymentStatus.NotFound: + 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 ReminderDto( + Email: managerEmail, + HoursOverdue: 0, + Url: BuildProbationUrl(type, row, listId), + AskOverdueReason: false, + RequestTypeId: type.Id, + RequestTypeName: type.Name, + RequestId: row.Id, + Status: row.Status, + RequestUniqueCode: row.RequestUniqueCode, + Category: ReminderCategory.ProbationEnding, + ManagerEmail: managerEmail, + CandidateEmail: candidateEmail, + CandidateName: candidateName, + ProbationPeriod: row.ProbationPeriod, + HireDate: row.HireDate, + ProbationEndsAt: probationEndsAt, + DaysUntilProbationEnds: daysLeft); + } + + /// + /// Ссылка на саму заявку (не на шаг согласования — этого вида напоминаний + /// понятие "фаза" не касается), в том же формате, что и BuildUrl. + /// + private string BuildProbationUrl(RequestType type, HireRow row, string listId) => + options.Value.RequestUrlTemplate + .Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/')) + .Replace("{pageUrl}", type.PageUrl) + .Replace("{requestId}", row.Id.ToString()) + .Replace("{listId}", Uri.EscapeDataString(listId)) + .Replace("{nav}", "approvals"); } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs index 2776b5e..30ce775 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs @@ -24,4 +24,14 @@ 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; } = 3; } 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/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 778b2e9..683d268 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -9,6 +9,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; @@ -53,6 +54,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 +122,7 @@ public sealed class ReminderServiceTests : IClassFixture(), sharePoint.Object, _services.GetRequiredService(), + Mock.Of(), Options.Create(new RemindersOptions()), NullLogger.Instance); } @@ -123,6 +142,7 @@ public sealed class ReminderServiceTests : IClassFixture + 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 = 3) + { + 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, + Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }), + 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(); + reminder.Category.ShouldBe(ReminderCategory.ProbationEnding); + reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); + reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); + reminder.Email.ShouldBe("manager@tnsu.kz"); + reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); + reminder.DaysUntilProbationEnds.ShouldBe(3); + } + + [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().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().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(); + } } -- 2.34.1 From 5d7503209abc30635dcf825aa301fd1417ade535 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 12:31:45 +0500 Subject: [PATCH 2/8] =?UTF-8?q?ReminderDto:=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=20ApprovalOverdue?= =?UTF-8?q?ReminderDto=20=D0=B8=20ProbationEndingReminderDto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Абстрактный базовый класс + два sealed-наследника вместо одного DTO с опциональными полями под обе категории — так специфичные для каждого вида напоминания поля не путаются с полями другого вида. Полиморфная сериализация System.Text.Json ([JsonPolymorphic]/[JsonDerivedType]) нужна, иначе список IReadOnlyList сериализовался бы только по общим полям базового класса. --- .../Reminders/ApprovalOverdueReminderDto.cs | 23 ++++++ .../Reminders/ProbationEndingReminderDto.cs | 25 +++++++ .../Reminders/ReminderDto.cs | 71 ++++++++----------- .../Reminders/ReminderService.cs | 29 ++++---- .../ReminderServiceTests.cs | 19 +++-- 5 files changed, 99 insertions(+), 68 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs new file mode 100644 index 0000000..5b738fd --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs @@ -0,0 +1,23 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// Согласующий/исполнитель из строки списка — кому напоминать. +/// +/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). +/// Выходные не учитываются — считаются календарные часы. +/// +/// +/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым +/// наследованием (шаг перекрывает тип заявки). +/// +public sealed record ApprovalOverdueReminderDto( + string Email, + int HoursOverdue, + bool AskOverdueReason, + string Url, + int RequestTypeId, + string RequestTypeName, + int RequestId, + string Status, + string? RequestUniqueCode) + : ReminderDto( + ReminderCategory.ApprovalOverdue, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs new file mode 100644 index 0000000..d46580f --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs @@ -0,0 +1,25 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// Email руководителя принимаемого сотрудника — кому напоминать. +/// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). +/// ФИО принимаемого сотрудника (колонка CandidateName списка данных). +/// Текст испытательного срока (колонка ProbationPeriod). +/// Дата приёма (колонка HireDate). +/// Дата окончания испытательного срока (HireDate + срок). +/// Сколько дней осталось до конца испытательного срока. +public sealed record ProbationEndingReminderDto( + string ManagerEmail, + string CandidateEmail, + string CandidateName, + string? ProbationPeriod, + DateTimeOffset HireDate, + DateTimeOffset ProbationEndsAt, + int DaysUntilProbationEnds, + string Url, + int RequestTypeId, + string RequestTypeName, + int RequestId, + string Status, + string? RequestUniqueCode) + : ReminderDto( + ReminderCategory.ProbationEnding, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs index b4ecc66..b3c5ad2 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs @@ -1,52 +1,41 @@ +using System.Text.Json.Serialization; + namespace Tnsu.ApprovalRoute.Application.Reminders; -/// Согласующий/исполнитель из строки списка — кому напоминать. -/// -/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). -/// Выходные не учитываются — считаются календарные часы. -/// -/// Ссылка на заявку, требующую решения. -/// -/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым -/// наследованием (шаг перекрывает тип заявки). -/// -/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»). +/// Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы. +/// Ссылка на заявку. +/// Id типа заявки (RequestType). +/// Название типа заявки — для читаемости в письме/таблице, без похода в БД. +/// Id заявки (TicketId строки списка согласования / Id строки списка данных). +/// Статус заявки/шага — из того списка, который читает конкретный вид напоминания. /// /// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у /// заявки его нет или строка данных не найдена. /// -/// -/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по -/// умолчанию («не указана»): старые вызовы -/// конструктора и потребители, ничего не знающие о категориях, продолжают работать -/// как раньше. -/// -/// -/// Только для — email руководителя -/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для -/// остальных категорий — null. -/// -/// Только для ProbationEnding — email принимаемого сотрудника. -/// Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName). -/// Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod). -/// Только для ProbationEnding — дата приёма (колонка HireDate). -/// Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок). -/// Только для ProbationEnding — сколько дней осталось до конца испытательного срока. -public sealed record ReminderDto( - string Email, - int HoursOverdue, +/// +/// Общая часть двух видов напоминаний — и +/// . У них разные поля-триггеры и разные +/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка +/// и как её открыть). +/// +/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на +/// compile-time тип элемента (ReminderService всегда работает со списком именно +/// этого базового типа, см. IReadOnlyList<ReminderDto>), System.Text.Json без +/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные +/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.). +/// Дискриминатор — отдельное системное поле "$type", а не Category: значение +/// Category читаемое (совпадает с именем enum-константы через уже подключенный +/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям +/// сериализации. +/// +[JsonPolymorphic] +[JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))] +[JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))] +public abstract record ReminderDto( + ReminderCategory Category, string Url, - bool AskOverdueReason, int RequestTypeId, string RequestTypeName, int RequestId, string Status, - string? RequestUniqueCode, - ReminderCategory Category = ReminderCategory.None, - string? ManagerEmail = null, - string? CandidateEmail = null, - string? CandidateName = null, - string? ProbationPeriod = null, - DateTimeOffset? HireDate = null, - DateTimeOffset? ProbationEndsAt = null, - int? DaysUntilProbationEnds = null); + string? RequestUniqueCode); \ No newline at end of file diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 032f864..5cfcf3d 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -203,17 +203,16 @@ 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, + BuildUrl(type, row, listId), type.Id, type.Name, row.TicketId, row.Status, - uniqueCodes.GetValueOrDefault(row.TicketId), - ReminderCategory.ApprovalOverdue)); + uniqueCodes.GetValueOrDefault(row.TicketId))); } return reminders; @@ -417,24 +416,20 @@ public sealed class ReminderService( return null; } - return new ReminderDto( - Email: managerEmail, - HoursOverdue: 0, - Url: BuildProbationUrl(type, row, listId), - AskOverdueReason: false, - RequestTypeId: type.Id, - RequestTypeName: type.Name, - RequestId: row.Id, - Status: row.Status, - RequestUniqueCode: row.RequestUniqueCode, - Category: ReminderCategory.ProbationEnding, + return new ProbationEndingReminderDto( ManagerEmail: managerEmail, CandidateEmail: candidateEmail, CandidateName: candidateName, ProbationPeriod: row.ProbationPeriod, - HireDate: row.HireDate, + HireDate: hireDate, ProbationEndsAt: probationEndsAt, - DaysUntilProbationEnds: daysLeft); + DaysUntilProbationEnds: daysLeft, + Url: BuildProbationUrl(type, row, listId), + RequestTypeId: type.Id, + RequestTypeName: type.Name, + RequestId: row.Id, + Status: row.Status, + RequestUniqueCode: row.RequestUniqueCode); } /// diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 683d268..b9ac9f3 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -138,7 +138,7 @@ public sealed class ReminderServiceTests : IClassFixture(); reminder.Email.ShouldBe("approver@tnsu.kz"); reminder.HoursOverdue.ShouldBe(24); reminder.RequestId.ShouldBe(1); @@ -173,7 +173,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(2); } [Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")] @@ -199,7 +199,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5); } // SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом @@ -219,7 +219,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5); } [Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")] @@ -231,7 +231,7 @@ public sealed class ReminderServiceTests : IClassFixture().AskOverdueReason.ShouldBeTrue(); } // Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён @@ -276,7 +276,7 @@ public sealed class ReminderServiceTests : IClassFixture().Email.ShouldBe("approver@tnsu.kz"); } private static object HireDataRow( @@ -355,11 +355,10 @@ public sealed class ReminderServiceTests : IClassFixture(); reminder.Category.ShouldBe(ReminderCategory.ProbationEnding); reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); - reminder.Email.ShouldBe("manager@tnsu.kz"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); reminder.DaysUntilProbationEnds.ShouldBe(3); } @@ -407,7 +406,7 @@ public sealed class ReminderServiceTests : IClassFixture().CandidateEmail.ShouldBe("romanov@tnsu.kz"); } // ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны @@ -423,7 +422,7 @@ public sealed class ReminderServiceTests : IClassFixture().CandidateEmail.ShouldBe("volkov@tnsu.kz"); } [Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")] -- 2.34.1 From cfc2b393bd85645c42bd1fed969dc4d740a27478 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 14:12:39 +0500 Subject: [PATCH 3/8] =?UTF-8?q?Reminders:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20Category=20=D0=B8=D0=B7=20=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=87=D0=B0=D1=81=D1=82=D0=B8=20DTO,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20ReceiverEmail/ReceiverNa?= =?UTF-8?q?me,=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D1=8B=D1=82=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=81=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D0=BC=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=86=D0=B8=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ReminderDto: базовый класс сузился до ReceiverEmail/ReceiverName/Url; RequestTypeId/RequestTypeName/RequestId/Status/RequestUniqueCode остались только в ApprovalOverdueReminderDto — ProbationEndingReminderDto они не нужны. Дискриминатор полиморфной сериализации ($type) больше не дублируется отдельным enum-полем Category. - ApprovalOverdueReminderDto: старый Email сохранён рядом с унаследованным ReceiverEmail (то же значение) — контракт можно переключать постепенно. - ApprovalRow/RowSelect: добавлен Title согласующего (TansuSiteApprover/Title) — заполняет ReceiverName для просроченных шагов, когда он есть в SharePoint. - EmployeeDirectorySnapshot.NameByEmail: обратный поиск ФИО руководителя по email — заполняет ReceiverName для напоминаний об испытательном сроке без лишнего похода в api.tnsu.kz (данные уже загружены для EmailOf). - GetProbationEndingAsync: испытательный срок не длиннее 6 месяцев, поэтому читаем только текущую и последнюю архивную партицию (не весь архив) и фильтруем строки по HireDate не старше 6 месяцев — экономим лишние запросы к SharePoint без потери актуальных заявок. --- .../Reminders/ApprovalOverdueReminderDto.cs | 22 +++++-- .../Reminders/ApprovalRow.cs | 12 ++-- .../Reminders/EmployeeDirectorySnapshot.cs | 14 ++++ .../Reminders/ProbationEndingReminderDto.cs | 14 ++-- .../Reminders/ReminderCategory.cs | 23 ------- .../Reminders/ReminderDto.cs | 53 +++++++-------- .../Reminders/ReminderService.cs | 32 ++++++--- .../ApplicationServices/ReminderDtoTests.cs | 32 +++++++++ .../ReminderServiceTests.cs | 65 ++++++++++++++++++- 9 files changed, 184 insertions(+), 83 deletions(-) delete mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs index 5b738fd..9ea1c62 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs @@ -1,6 +1,10 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; -/// Согласующий/исполнитель из строки списка — кому напоминать. +/// +/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым +/// именем ради обратной совместимости с потребителями, читавшими это поле до +/// появления общего ReceiverEmail/ReceiverName в базовом классе. +/// /// /// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). /// Выходные не учитываются — считаются календарные часы. @@ -9,15 +13,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; /// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым /// наследованием (шаг перекрывает тип заявки). /// +/// Id типа заявки (RequestType). +/// Название типа заявки — для читаемости в письме/таблице, без похода в БД. +/// Id заявки (TicketId строки списка согласования). +/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»). +/// +/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у +/// заявки его нет или строка данных не найдена. +/// public sealed record ApprovalOverdueReminderDto( string Email, int HoursOverdue, bool AskOverdueReason, - string Url, int RequestTypeId, string RequestTypeName, int RequestId, string Status, - string? RequestUniqueCode) - : ReminderDto( - ReminderCategory.ApprovalOverdue, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode); + 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 index 64b8cf0..f82fd86 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs @@ -31,12 +31,22 @@ 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); } /// @@ -64,6 +74,10 @@ internal sealed class EmployeeDirectorySnapshot .Select(e => e.Email) .FirstOrDefault(email => !string.IsNullOrWhiteSpace(email)); + /// ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf). + public string? NameByEmail(string email) => + _fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant()); + /// /// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта /// регистра и лишних пробелов между словами (в живых данных встречаются и diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs index d46580f..b42fa84 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs @@ -1,6 +1,9 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; -/// Email руководителя принимаемого сотрудника — кому напоминать. +/// +/// То же значение, что и унаследованный ReceiverEmail, — email руководителя +/// принимаемого сотрудника, кому адресовано напоминание. +/// /// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). /// ФИО принимаемого сотрудника (колонка CandidateName списка данных). /// Текст испытательного срока (колонка ProbationPeriod). @@ -16,10 +19,5 @@ public sealed record ProbationEndingReminderDto( DateTimeOffset ProbationEndsAt, int DaysUntilProbationEnds, string Url, - int RequestTypeId, - string RequestTypeName, - int RequestId, - string Status, - string? RequestUniqueCode) - : ReminderDto( - ReminderCategory.ProbationEnding, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode); + string? ReceiverName = null) + : ReminderDto(ManagerEmail, ReceiverName, Url); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs deleted file mode 100644 index 15faba3..0000000 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Tnsu.ApprovalRoute.Application.Reminders; - -/// -/// Категория уведомления в . Добавлена позже самого DTO, -/// поэтому в нём параметр необязательный со значением — -/// «категория не указана»: ровно то, что было до появления этого поля, когда -/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate) -/// ни о каких категориях не знали. -/// -/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs), -/// так что числовые значения — деталь реализации, менять их порядок безопасно. -/// -public enum ReminderCategory -{ - /// Категория не указана — значение по умолчанию для обратной совместимости. - None = 0, - - /// Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync). - ApprovalOverdue = 1, - - /// Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync). - ProbationEnding = 2, -} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs index b3c5ad2..3563423 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs @@ -2,40 +2,31 @@ using System.Text.Json.Serialization; namespace Tnsu.ApprovalRoute.Application.Reminders; -/// Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы. -/// Ссылка на заявку. -/// Id типа заявки (RequestType). -/// Название типа заявки — для читаемости в письме/таблице, без похода в БД. -/// Id заявки (TicketId строки списка согласования / Id строки списка данных). -/// Статус заявки/шага — из того списка, который читает конкретный вид напоминания. -/// -/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у -/// заявки его нет или строка данных не найдена. +/// Email того, кому адресовано напоминание. +/// +/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего +/// в старой строке списка без заполненного Title персонального поля) — тогда null. +/// +/// +/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя), +/// потому что не константна: requestId и партиция списка (server-relative url, +/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает — +/// дублировать эту логику снаружи незачем. /// /// /// Общая часть двух видов напоминаний — и -/// . У них разные поля-триггеры и разные -/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка -/// и как её открыть). +/// . Категории заявки (RequestTypeId, +/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования +/// (Power Automate ссылается на неё, работая со списком согласования конкретного +/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto. /// -/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на -/// compile-time тип элемента (ReminderService всегда работает со списком именно -/// этого базового типа, см. IReadOnlyList<ReminderDto>), System.Text.Json без -/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные -/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.). -/// Дискриминатор — отдельное системное поле "$type", а не Category: значение -/// Category читаемое (совпадает с именем enum-константы через уже подключенный -/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям -/// сериализации. +/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт +/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих +/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь, +/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type" +/// со значением "ApprovalOverdue"/"ProbationEnding". /// [JsonPolymorphic] -[JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))] -[JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))] -public abstract record ReminderDto( - ReminderCategory Category, - string Url, - int RequestTypeId, - string RequestTypeName, - int RequestId, - string Status, - string? RequestUniqueCode); \ No newline at end of file +[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/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 5cfcf3d..1becb20 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -207,12 +207,13 @@ public sealed class ReminderService( row.ApproverEmail!, (int)Math.Floor((now - deadline).TotalHours), row.AskOverdueReason ?? type.AskOverdueReason ?? false, - BuildUrl(type, row, listId), type.Id, type.Name, row.TicketId, row.Status, - uniqueCodes.GetValueOrDefault(row.TicketId))); + uniqueCodes.GetValueOrDefault(row.TicketId), + BuildUrl(type, row, listId), + row.ApproverName)); } return reminders; @@ -249,7 +250,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) @@ -316,7 +317,16 @@ public sealed class ReminderService( var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); - var partitions = archived.Select(x => (x.ListId, x.Number)).Append((current.ListId, current.Number)); + + // Испытательный срок — не больше 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 нет, а нужны @@ -356,8 +366,14 @@ public sealed class ReminderService( 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}'"; + $"$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(); @@ -425,11 +441,7 @@ public sealed class ReminderService( ProbationEndsAt: probationEndsAt, DaysUntilProbationEnds: daysLeft, Url: BuildProbationUrl(type, row, listId), - RequestTypeId: type.Id, - RequestTypeName: type.Name, - RequestId: row.Id, - Status: row.Status, - RequestUniqueCode: row.RequestUniqueCode); + ReceiverName: directory.NameByEmail(managerEmail)); } /// 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 b9ac9f3..a2415ec 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; @@ -142,7 +143,6 @@ public sealed class ReminderServiceTests : IClassFixture(); - reminder.Category.ShouldBe(ReminderCategory.ProbationEnding); reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); @@ -471,4 +470,66 @@ public sealed class ReminderServiceTests : IClassFixture(); + 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, + Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }), + 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); + } } -- 2.34.1 From 554f9b25286ed73fb9334cb0e9c6f6185282cab4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 15:21:34 +0500 Subject: [PATCH 4/8] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/RemindersController.cs | 9 ++-- backend/Tnsu.ApprovalRoute.Api/Program.cs | 1 + .../Reminders/ReminderFeed.cs | 16 +++++++ .../Reminders/ReminderService.cs | 46 ++++++++++++++----- .../ReminderServiceTests.cs | 40 +++++++++++++++- 5 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs index 584abb8..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.GetRemindersAsync(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/ReminderFeed.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs new file mode 100644 index 0000000..1b3dd50 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs @@ -0,0 +1,16 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Какой вид напоминаний запросить у . +/// +/// GetRemindersAsync изначально (до появления ProbationEnding) отдавал только +/// просроченные шаги согласования — этот вид и остаётся значением по умолчанию +/// (0), чтобы старые потребители (Power Automate), не указывающие feed явно, +/// продолжали получать ровно то же, что и раньше. +/// +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 1becb20..ac1d089 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -24,6 +24,7 @@ public sealed class ReminderService( ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, ITnsuPortalClient portalClient, + TimeProvider timeProvider, IOptions options, ILogger logger) { @@ -32,17 +33,30 @@ public sealed class ReminderService( private const string ActiveStatusFilter = $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; - /// Оба вида напоминаний разом — то, что реально отдаёт контроллер. - public async Task>> GetRemindersAsync( - DateTimeOffset now, CancellationToken cancellationToken) + /// + /// Оверлоад без 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 overdue = await GetOverdueAsync(now, cancellationToken); - if (overdue.IsFailed) return overdue; - - var probation = await GetProbationEndingAsync(now, cancellationToken); - if (probation.IsFailed) return probation; - - return Result.Ok>(overdue.Value.Concat(probation.Value).ToList()); + var now = timeProvider.GetUtcNow(); + return feed switch + { + ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken), + _ => GetOverdueAsync(now, cancellationToken), + }; } public async Task>> GetOverdueAsync( @@ -191,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; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index a2415ec..69eacf9 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -33,6 +33,16 @@ public sealed class ReminderServiceTests : IClassFixture now; + } + + private static readonly TimeProvider Clock = new FixedTimeProvider(Now); + private readonly ServiceProvider _services; private readonly RequestTypeService _requestTypeService; @@ -124,6 +134,7 @@ public sealed class ReminderServiceTests : IClassFixture(), Mock.Of(), + Clock, Options.Create(new RemindersOptions()), NullLogger.Instance); } @@ -145,6 +156,32 @@ public sealed class ReminderServiceTests : IClassFixture().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() { @@ -317,6 +354,7 @@ public sealed class ReminderServiceTests : IClassFixture(), portalClient.Object, + Clock, Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }), NullLogger.Instance); } @@ -519,7 +557,7 @@ public sealed class ReminderServiceTests : IClassFixture.Instance); -- 2.34.1 From 81ed85d6ce3ccd8ff0a291fe09be4c28a9fe397a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 15:23:45 +0500 Subject: [PATCH 5/8] =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B9=D0=BB=D0=B3=D0=B0?= =?UTF-8?q?=D0=B9=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 CLAUDE.md 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. -- 2.34.1 From 31a0a8e9e30120bccb3472d12c1ecfda3e1f94c8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 15:53:31 +0500 Subject: [PATCH 6/8] =?UTF-8?q?=D0=9D=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8C=20ManagerEmail,=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20=D0=B8=D0=B7=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA;=20None=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D1=8B=D0=BC=20=D0=B2=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Напоминания: - ProbationEndingReminderDto: ManagerEmail убран — это был унаследованный ReceiverEmail под вторым именем. - Ссылка для ProbationEnding больше не ведёт на саму заявку на приём (она уже закрыта, решать по ней нечего) — берётся из новой настройки RemindersOptions.ProbationReminderUrl и ведёт в раздел, где заводят новую заявку. Подстановок в ней нет: адрес конкретной формы в портал не передать, rptype без rpid/rpsource игнорируется (см. parseRoute в request-portal). Заодно ушли BuildProbationUrl и ненужные больше параметры type/listId. Стайлгайд (CLAUDE.md) — у enum первое значение None: - ReminderFeed: None=0 трактуется как ApprovalOverdue, чтобы вызов без явного выбора работал как раньше. - EmploymentStatus: заодно инвертирован switch в ReminderService — раньше «продолжить» было веткой по умолчанию, и новый None молча означал бы «сотрудник работает». Теперь по умолчанию пропуск с предупреждением. - ConditionsLogic: None — НЕвалидное значение, отвергается валидаторами шага (в том числе новым PatchTemplateRouteStepRequestValidator, его не было вовсе). Поэтому у ConditionsLogic в Create/Update-запросах убрано значение по умолчанию: System.Text.Json в .NET 10 уважает дефолты параметров конструктора, и пропущенное поле молча означало «И». Теперь пропущенное поле даёт None и упирается в валидатор. ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ КОНТРАКТА: клиент, не присылающий conditionsLogic в POST/PUT шага, получит 400 вместо прежнего молчаливого «И». Админка не затронута — она это поле всегда отправляет. --- .../Reminders/EmployeeDirectorySnapshot.cs | 13 +++- .../Reminders/ProbationEndingReminderDto.cs | 11 +-- .../Reminders/ReminderFeed.cs | 11 ++- .../Reminders/ReminderService.cs | 36 +++++---- .../Reminders/RemindersOptions.cs | 15 ++++ .../TemplateRouteStepDto.cs | 4 +- .../TemplateRouteStepValidators.cs | 35 +++++++++ .../Enums/ConditionsLogic.cs | 16 +++- .../ReminderServiceTests.cs | 19 ++++- .../TemplateRouteServiceTests.cs | 56 ++++++------- .../TemplateRouteStepServiceTests.cs | 78 ++++++++++--------- 11 files changed, 192 insertions(+), 102 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs index f82fd86..9ea516d 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs @@ -4,14 +4,21 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; internal enum EmploymentStatus { + /// + /// Статус не определяли. EmploymentOf такого не возвращает — значение существует + /// только как значение по умолчанию (default(EmploymentStatus)); трактуется + /// наравне с , то есть «трудоустройство не подтверждено». + /// + None = 0, + /// Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено. - NotFound, + NotFound = 1, /// Работает хотя бы в одной компании. - Employed, + Employed = 2, /// Есть в справочнике, но уволен из всех компаний, где числился. - Dismissed, + Dismissed = 3, } /// diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs index b42fa84..3a810ab 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs @@ -1,8 +1,9 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; -/// -/// То же значение, что и унаследованный ReceiverEmail, — email руководителя -/// принимаемого сотрудника, кому адресовано напоминание. +/// +/// Email руководителя принимаемого сотрудника — кому адресовано напоминание. +/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение +/// под вторым именем. /// /// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). /// ФИО принимаемого сотрудника (колонка CandidateName списка данных). @@ -11,7 +12,7 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; /// Дата окончания испытательного срока (HireDate + срок). /// Сколько дней осталось до конца испытательного срока. public sealed record ProbationEndingReminderDto( - string ManagerEmail, + string ReceiverEmail, string CandidateEmail, string CandidateName, string? ProbationPeriod, @@ -20,4 +21,4 @@ public sealed record ProbationEndingReminderDto( int DaysUntilProbationEnds, string Url, string? ReceiverName = null) - : ReminderDto(ManagerEmail, ReceiverName, Url); + : ReminderDto(ReceiverEmail, ReceiverName, Url); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs index 1b3dd50..33085df 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs @@ -1,16 +1,15 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; /// -/// Какой вид напоминаний запросить у . +/// Какой вид напоминаний запросить у . /// -/// GetRemindersAsync изначально (до появления ProbationEnding) отдавал только -/// просроченные шаги согласования — этот вид и остаётся значением по умолчанию -/// (0), чтобы старые потребители (Power Automate), не указывающие feed явно, -/// продолжали получать ровно то же, что и раньше. +/// («вид не указан») трактуется как : +/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и +/// вызов без явного выбора обязан работать как раньше. /// public enum ReminderFeed { None = 0, ApprovalOverdue = 1, - ProbationEnding = 2 + ProbationEnding = 2, } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index ac1d089..45cac5b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -403,15 +403,20 @@ public sealed class ReminderService( var reminders = new List(); foreach (var row in rows) { - var reminder = await TryBuildProbationReminderAsync(type, row, listId, directory, now, cancellationToken); + 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( - RequestType type, HireRow row, string listId, EmployeeDirectorySnapshot directory, + HireRow row, EmployeeDirectorySnapshot directory, DateTimeOffset now, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null; @@ -425,11 +430,17 @@ public sealed class ReminderService( var candidateName = row.CandidateName.Trim(); + // Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default: + // так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None, + // если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не + // молча считается «работает». switch (directory.EmploymentOf(candidateName)) { + case EmploymentStatus.Employed: + break; case EmploymentStatus.Dismissed: return null; // уже уволен — напоминать не о чем. - case EmploymentStatus.NotFound: + default: logger.LogWarning( "Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено", row.Id, candidateName); @@ -455,26 +466,17 @@ public sealed class ReminderService( } return new ProbationEndingReminderDto( - ManagerEmail: managerEmail, + ReceiverEmail: managerEmail, CandidateEmail: candidateEmail, CandidateName: candidateName, ProbationPeriod: row.ProbationPeriod, HireDate: hireDate, ProbationEndsAt: probationEndsAt, DaysUntilProbationEnds: daysLeft, - Url: BuildProbationUrl(type, row, listId), + // Не ссылка на саму заявку на приём (она уже закрыта, решать по ней + // нечего) — константный адрес раздела, где руководитель заводит новую + // заявку по итогам испытательного срока. См. ProbationReminderUrl. + Url: options.Value.ProbationReminderUrl, ReceiverName: directory.NameByEmail(managerEmail)); } - - /// - /// Ссылка на саму заявку (не на шаг согласования — этого вида напоминаний - /// понятие "фаза" не касается), в том же формате, что и BuildUrl. - /// - private string BuildProbationUrl(RequestType type, HireRow row, string listId) => - options.Value.RequestUrlTemplate - .Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/')) - .Replace("{pageUrl}", type.PageUrl) - .Replace("{requestId}", row.Id.ToString()) - .Replace("{listId}", Uri.EscapeDataString(listId)) - .Replace("{nav}", "approvals"); } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs index 30ce775..5e83987 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs @@ -34,4 +34,19 @@ public sealed class RemindersOptions /// За сколько дней до конца испытательного срока присылать напоминание. public int ProbationReminderDaysBeforeEnd { get; set; } = 3; + + /// + /// Куда ведёт ссылка в напоминании об окончании испытательного срока — на + /// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от + /// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение + /// по итогам испытательного срока), а не открыть уже закрытую старую. + /// + /// Поэтому ссылка константная, без подстановок: адрес конкретной формы в + /// портал вообще не передать — параметр 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/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 69eacf9..6787ab6 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -31,6 +31,11 @@ public sealed class ReminderServiceTests : IClassFixture(), portalClient.Object, Clock, - Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }), + Options.Create(new RemindersOptions + { + RecruitingHirePageUrl = pageUrl, + ProbationReminderDaysBeforeEnd = daysBeforeEnd, + ProbationReminderUrl = ProbationReminderUrl + }), NullLogger.Instance); } @@ -394,10 +404,13 @@ public sealed class ReminderServiceTests : IClassFixture(); - reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); + 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 заполнен) — напоминания нет")] @@ -558,7 +571,7 @@ public sealed class ReminderServiceTests : IClassFixture.Instance); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); 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 Date: Thu, 27 Aug 2026 16:02:50 +0500 Subject: [PATCH 7/8] =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reminders/ReminderService.cs | 24 ++++++++++++++++--- .../ReminderServiceTests.cs | 24 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 45cac5b..f38ffd4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -312,6 +312,19 @@ 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 { @@ -424,8 +437,11 @@ public sealed class ReminderService( if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months)) return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего. - var probationEndsAt = hireDate.AddMonths(months); - var daysLeft = (probationEndsAt.Date - now.Date).Days; + // Обе даты — в местном поясе (см. 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(); @@ -470,7 +486,9 @@ public sealed class ReminderService( CandidateEmail: candidateEmail, CandidateName: candidateName, ProbationPeriod: row.ProbationPeriod, - HireDate: hireDate, + // Тоже в местном поясе — иначе в письме дата приёма была бы на сутки + // раньше той, от которой посчитан ProbationEndsAt рядом. + HireDate: hireDateLocal, ProbationEndsAt: probationEndsAt, DaysUntilProbationEnds: daysLeft, // Не ссылка на саму заявку на приём (она уже закрыта, решать по ней diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 6787ab6..2f663a2 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -522,6 +522,30 @@ public sealed class ReminderServiceTests : IClassFixture(); + 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), поэтому заявка, способная дать напоминание сегодня, // физически не может лежать раньше предпоследней партиции. Три партиции: -- 2.34.1 From b9aac36a2bc20110ede15b43c7dc220e8f594281 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 16:26:32 +0500 Subject: [PATCH 8/8] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=207=20?= =?UTF-8?q?=D0=B4=D0=BD=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reminders/RemindersOptions.cs | 10 ++++++++-- .../ApplicationServices/ReminderServiceTests.cs | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs index 5e83987..51352da 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs @@ -32,8 +32,14 @@ public sealed class RemindersOptions /// public string RecruitingHirePageUrl { get; set; } = "recruiting-hire"; - /// За сколько дней до конца испытательного срока присылать напоминание. - public int ProbationReminderDaysBeforeEnd { get; set; } = 3; + /// + /// За сколько дней до конца испытательного срока присылать напоминание — неделя, + /// чтобы у руководителя было время подготовить решение по итогам срока. + /// + /// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до + /// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной. + /// + public int ProbationReminderDaysBeforeEnd { get; set; } = 7; /// /// Куда ведёт ссылка в напоминании об окончании испытательного срока — на diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 2f663a2..e1f4d0a 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -36,6 +36,11 @@ public sealed class ReminderServiceTests : IClassFixture rows, Mock portalClient, - int daysBeforeEnd = 3) + int daysBeforeEnd = ProbationDaysBeforeEnd) { const string hireDataListTitle = "Прием на работу тест — данные"; @@ -595,7 +600,14 @@ public sealed class ReminderServiceTests : IClassFixture.Instance); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); -- 2.34.1