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(); + } }