using System.Text.Json; using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; 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; /// /// Кто прямо сейчас просрочил свой шаг согласования или исполнения. /// /// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный /// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано — /// рассылкой и защитой от повторов занимается вызывающая сторона (Power /// Automate по расписанию). /// public sealed class ReminderService( ApprovalRouteDbContext db, ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, ITnsuPortalClient portalClient, IOptions options, ILogger logger) { // $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск, // поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов). 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) { // Точка входа — типы заявок с настроенным SLA. Тип без SLA не участвует // в напоминаниях вообще, даже если у его шагов SLA задан: включение // напоминаний — решение уровня типа заявки. var types = await db.RequestTypes.AsNoTracking() .Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); // Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не // потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких // веток Task.WhenAll ниже валит его исключением ("A second operation // was started on this context instance before a previous operation // completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо // проглатывает — часть напоминаний молча пропадала бы без всякой // видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность // здесь не стоит ощутимого времени. var archivedByType = new Dictionary>(); foreach (var type in types) archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken); // Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не // трогают) — обход распараллелен, иначе время растёт линейно с числом // типов: каждый тип сам по себе делает несколько последовательных // HTTP-запросов. var results = await Task.WhenAll( types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken))); return Result.Ok>(results.SelectMany(r => r).ToList()); } private async Task> GetOverdueForTypeSafeAsync( RequestType type, IReadOnlyList archived, DateTimeOffset now, CancellationToken cancellationToken) { try { return await GetOverdueForTypeAsync(type, archived, now, cancellationToken); } catch (Exception e) { // Недоступный список одного типа не должен обнулять напоминания по // всем остальным: Power Automate получит то, что удалось собрать. logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name); return []; } } /// /// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в /// архив, продолжает ждать чьего-то решения точно так же, как в текущем /// списке, и не должна пропадать из напоминаний только из-за того, что для /// новых заявок завели следующий список. /// /// archived — уже прочитан вызывающей стороной (GetOverdueAsync, /// последовательно для всех типов разом) — здесь БД больше не трогаем, /// только HTTP к SharePoint, поэтому вызов можно параллелить по типам. /// private async Task> GetOverdueForTypeAsync( RequestType type, IReadOnlyList archived, DateTimeOffset now, CancellationToken cancellationToken) { var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); var partitions = archived .Select(x => (x.ListId, x.Number)) .Append((current.ListId, current.Number)); var reminders = new List(); foreach (var (listId, number) in partitions) { try { reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken)); } catch (Exception e) { // Недоступная партиция (например, архивный список переименовали // или удалили вручную) не должна обнулять напоминания по // остальным партициям того же типа. logger.LogError( e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны", listId, number, type.Name); } } return reminders; } private async Task> GetOverdueForPartitionAsync( RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; // Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url // список найдётся всегда. var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) { logger.LogWarning( "Список данных партиции №{Number} типа «{Type}» не найден по url {Url}", number, type.Name, listId); return []; } var approvalListTitle = ApprovalListTitleFor(dataListTitle); if (approvalListTitle is null) { logger.LogWarning( "Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти", dataListTitle); return []; } // Активные строки — прямым $filter по полю ApprovalStatus. // // Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) — // тот же приём, что в request-portal (см. MP_STATUS). На практике он // строки не нашёл: индекс мог не подхватить именно эту managed property // для этого списка (она формируется автоматически, и после провижининга // колонок SLA переиндексация могла ещё не пройти) — сама причина не // подтверждена, а вот результат — нет. Прямой $filter по полю сработал: // он не отстаёт от реального состояния списка (не зависит от индексации // вовсе), и подтверждённой на практике проблемы с ним на этом списке нет. // // Дата активации строки — прямо в ней самой (колонка Date): request-portal // проставляет её в момент перехода строки в «На согласовании»/«На // исполнении» (см. approvalList.service.ts/setActive) и никак иначе, // поэтому больше не нужно ни читать весь маршрут заявки, ни дату // создания заявки, ни выводить активацию из решений соседних шагов. var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken); if (active.Count == 0) return []; var uniqueCodes = await ReadUniqueCodesAsync( siteUrl, type.ListId!, active.Select(r => r.TicketId).Distinct(), cancellationToken); var reminders = new List(); foreach (var row in active) { if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue; if (row.Date is not { } activatedAt) { // Строка активна, но Date пуста — так бывает у строк, заведённых // ДО появления колонки (см. ApprovalRow) или если request-portal // почему-то не проставил её при активации. Без даты старта SLA не // от чего отсчитывать — пропускаем, а не подставляем сейчас. logger.LogWarning( "Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена", row.Id, row.TicketId); continue; } // SLA строки перекрывает типовой. У строк, созданных до появления // колонки, значения нет — тогда работает SLA типа заявки. var slaHours = row.SlaHours ?? type.SlaHours!.Value; var deadline = activatedAt.AddHours(slaHours); if (deadline >= now) continue; reminders.Add(new ApprovalOverdueReminderDto( row.ApproverEmail!, (int)Math.Floor((now - deadline).TotalHours), row.AskOverdueReason ?? type.AskOverdueReason ?? false, type.Id, type.Name, row.TicketId, row.Status, uniqueCodes.GetValueOrDefault(row.TicketId), BuildUrl(type, row, listId), row.ApproverName)); } return reminders; } /// /// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг: /// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно /// откроется панелью поверх раздела (rpmode=approve). /// /// listId — server-relative url партиции (см. shared/routing.ts в /// request-portal, параметр rplist): без него архивная заявка открылась бы /// так, будто лежит в ТЕКУЩЕЙ партиции типа, и портал не нашёл бы её. /// private string BuildUrl(RequestType type, ApprovalRow row, string listId) => options.Value.RequestUrlTemplate .Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/')) .Replace("{pageUrl}", type.PageUrl) .Replace("{requestId}", row.TicketId.ToString()) .Replace("{listId}", Uri.EscapeDataString(listId)) .Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals"); /// «Заявка на приём — данные» → «Заявка на приём — согласование». private static string? ApprovalListTitleFor(string dataListTitle) { var index = dataListTitle.LastIndexOf('—'); if (index < 0) return null; var suffix = dataListTitle[(index + 1)..].Trim(); return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase) ? $"{dataListTitle[..index].TrimEnd()} — согласование" : null; } private const string RowSelect = "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," + "TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000"; private async Task> ReadRowsAsync( string siteUrl, string listTitle, string filter, CancellationToken cancellationToken) { var items = await sharePoint.GetItemsByListTitleAsync( siteUrl, listTitle, $"{RowSelect}&{filter}", cancellationToken); return Parse(items); } private static List Parse(IEnumerable items) => items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); /// /// RequestUniqueCode заявок из списка данных — только по заявкам, у которых /// сейчас есть просроченный шаг (а не по всему списку: он может быть большим). /// private async Task> ReadUniqueCodesAsync( string siteUrl, string dataListUrl, IEnumerable ticketIds, CancellationToken cancellationToken) { var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}")); if (filter.Length == 0) return new Dictionary(); var items = await sharePoint.GetItemsByListUrlAsync( siteUrl, dataListUrl, $"$select=Id,RequestUniqueCode&$filter={filter}", cancellationToken); var result = new Dictionary(); foreach (var item in items) { if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) continue; result[id] = item.TryGetProperty("RequestUniqueCode", out var code) && code.ValueKind == JsonValueKind.String ? code.GetString() : null; } 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); // Испытательный срок — не больше 6 месяцев (см. ProbationMonthsByOption), // поэтому заявка, способная дать напоминание сегодня, обязана лежать в // текущей партиции или в партиции прямо перед ней: более старая партиция // физически не может содержать ещё актуальный испытательный срок. Читать // весь архив, как это делает GetOverdueAsync (там заявка может висеть // непринятой годами), здесь незачем — только лишняя нагрузка на SharePoint. var lastArchived = archived.OrderByDescending(x => x.Number).FirstOrDefault(); var partitions = new List<(string ListId, int Number)> { (current.ListId, current.Number) }; if (lastArchived is not null) partitions.Add((lastArchived.ListId, lastArchived.Number)); // Обе базы сотрудников читаются по одному разу на весь обход партиций (а не // на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны // сразу и дата увольнения (только в FullBase), и email (только в All). var directory = new EmployeeDirectorySnapshot( await portalClient.GetFullBaseAsync(cancellationToken), await portalClient.GetAllEmployeesAsync(cancellationToken)); var reminders = new List(); foreach (var (listId, number) in partitions) { try { reminders.AddRange( await GetProbationEndingForPartitionAsync(type, listId, directory, now, cancellationToken)); } catch (Exception e) { logger.LogError( e, "Напоминания об испытательном сроке по списку {ListId} (партиция №{Number}) не собраны", listId, number); } } return Result.Ok>(reminders); } private const string HireRowSelect = "$select=Id,RequestStatus,CandidateName,HireDate,ProbationPeriod,RequestUniqueCode&$top=5000"; private async Task> GetProbationEndingForPartitionAsync( RequestType type, string listId, EmployeeDirectorySnapshot directory, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) return []; // HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две // последних партиции (см. GetProbationEndingAsync): более старый приём уже // не может дать напоминание, читать его строки незачем даже в пределах // партиции, где они могут лежать вперемешку со свежими. var since = now.AddMonths(-6); var filter = $"$filter=(RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}')" + $" and HireDate ge datetime'{since:yyyy-MM-ddTHH:mm:ssZ}'"; var items = await sharePoint.GetItemsByListTitleAsync( siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken); var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); var reminders = new List(); foreach (var row in rows) { var reminder = await TryBuildProbationReminderAsync(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 ProbationEndingReminderDto( ManagerEmail: managerEmail, CandidateEmail: candidateEmail, CandidateName: candidateName, ProbationPeriod: row.ProbationPeriod, HireDate: hireDate, ProbationEndsAt: probationEndsAt, DaysUntilProbationEnds: daysLeft, Url: BuildProbationUrl(type, row, listId), 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"); }