using System.Text.Json; using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; namespace Tnsu.ApprovalRoute.Application.Reminders; /// /// Кто прямо сейчас просрочил свой шаг согласования или исполнения. /// /// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный /// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано — /// рассылкой и защитой от повторов занимается вызывающая сторона (Power /// Automate по расписанию). /// public sealed class ReminderService( ApprovalRouteDbContext db, ISharePointClient sharePoint, IOptions options, ILogger logger) { // $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск, // поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов). private const string ActiveStatusFilter = $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; 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); var reminders = new List(); foreach (var type in types) { try { reminders.AddRange(await GetOverdueForTypeAsync(type, now, cancellationToken)); } catch (Exception e) { // Недоступный список одного типа не должен обнулять напоминания по // всем остальным: Power Automate получит то, что удалось собрать. logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name); } } return Result.Ok>(reminders); } private async Task> GetOverdueForTypeAsync( RequestType type, DateTimeOffset now, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) { logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.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 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 ReminderDto( row.ApproverEmail!, (int)Math.Floor((now - deadline).TotalHours), BuildUrl(type, row), row.AskOverdueReason ?? type.AskOverdueReason ?? false, type.Id, type.Name, row.TicketId)); } return reminders; } /// /// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг: /// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно /// откроется панелью поверх раздела (rpmode=approve). /// private string BuildUrl(RequestType type, ApprovalRow row) => options.Value.RequestUrlTemplate .Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/')) .Replace("{pageUrl}", type.PageUrl) .Replace("{requestId}", row.TicketId.ToString()) .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&$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(); }