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) { // Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе // на большом списке получится неподъёмной длины URL. private const int IdChunkSize = 50; // Верхняя граница на число одновременно активных шагов по типу заявки — // запас с большим избытком (это не общий объём заявок, а только те, что // прямо сейчас на согласовании/исполнении). private const int SearchRowLimit = 5000; 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 []; } // Шаг 1. Активные строки — через Search, а не через $filter: дело не в // кириллице конкретно, а в том, что $filter по Choice-колонке в // SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо // ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки // этого же списка уже проверена и используется в request-portal // (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут. // // Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ // нерешённые строки списка (queued + active), чтобы отобрать активные // на клиенте — при большом архиве заявок это росло бы неограниченно. // Search сужает набор на стороне SharePoint до тех, что реально нужны. var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken); if (string.IsNullOrWhiteSpace(relativeUrl)) { logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle); return []; } var pathClause = $"Path:\"{siteUrl.TrimEnd('/')}{relativeUrl}*\""; var statusClause = $"({SlaStatus.OnApprovalProperty} OR {SlaStatus.OnExecutionProperty})"; var activeIds = await sharePoint.SearchListItemIdsAsync( siteUrl, $"{pathClause} AND {statusClause}", SearchRowLimit, cancellationToken); if (activeIds.Count == 0) return []; // Search отдаёт только Id — реальные поля (email, SlaHours, TicketId и // т.д.) дочитываем REST'ом, как и везде в этом сервисе (см. ReadCreatedAsync). // Заодно и статус здесь берётся из свежих данных, а не из индекса, который // мог успеть отстать (та же оговорка, что в request-portal). var active = (await ReadRowsByIdsAsync(siteUrl, approvalListTitle, activeIds, cancellationToken)) .Where(r => r.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution) .ToList(); if (active.Count == 0) return []; // Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута — // дата активации шага выводится из дат решений предыдущего шага. var ticketIds = active.Select(r => r.TicketId).Distinct().ToList(); var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken); var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList()); var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken); var reminders = new List(); foreach (var row in active) { if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue; if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue; createdByTicket.TryGetValue(row.TicketId, out var created); var activatedAt = ActivatedAt(row, ticketRows, created); if (activatedAt is null) { logger.LogWarning( "Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена", row.Id, row.TicketId); continue; } // SLA строки перекрывает типовой. У строк, созданных до появления // колонки, значения нет — тогда работает SLA типа заявки. var slaHours = row.SlaHours ?? type.SlaHours!.Value; var deadline = activatedAt.Value.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; } /// /// Момент, с которого шаг считается активным, — начало отсчёта SLA. /// /// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это /// точечное обновление статуса, дату оно не пишет), поэтому момент выводится /// из соседних строк: /// • первый шаг согласования — дата создания заявки; /// • первый шаг исполнения — последнее решение по фазе согласования /// (фаза исполнения активируется только когда согласование пройдено целиком); /// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы. /// /// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить /// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг /// активируется только после решения последнего из них. /// /// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не /// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по /// условию появления). /// private static DateTimeOffset? ActivatedAt( ApprovalRow row, IReadOnlyList ticketRows, DateTimeOffset? requestCreated) { var samePhase = ticketRows.Where(r => r.Phase == row.Phase).ToList(); var isFirstInPhase = samePhase.All(r => r.StepOrder >= row.StepOrder); if (isFirstInPhase) { return row.Phase == ApprovalPhases.Execution ? LastDecisionOf(ticketRows.Where(r => r.Phase == ApprovalPhases.Approval)) : requestCreated; } var previousOrder = samePhase .Where(r => r.StepOrder < row.StepOrder) .Max(r => r.StepOrder); return LastDecisionOf(samePhase.Where(r => r.StepOrder == previousOrder)); } private static DateTimeOffset? LastDecisionOf(IEnumerable rows) { var dates = rows.Select(r => r.Date).Where(d => d is not null).Select(d => d!.Value).ToList(); return dates.Count == 0 ? null : dates.Max(); } /// /// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг: /// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно /// откроется панелью поверх раздела (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,StepOrder,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); } /// Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync). private async Task> ReadRowsByIdsAsync( string siteUrl, string listTitle, IReadOnlyList ids, CancellationToken cancellationToken) { var rows = new List(); foreach (var chunk in ids.Chunk(IdChunkSize)) { var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}")); rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken)); } return rows; } private async Task> ReadRowsByTicketsAsync( string siteUrl, string listTitle, IReadOnlyList ticketIds, CancellationToken cancellationToken) { var rows = new List(); foreach (var chunk in ticketIds.Chunk(IdChunkSize)) { var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"TicketId eq {id}")); rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken)); } return rows; } /// Даты создания заявок — начало отсчёта для первого шага согласования. private async Task> ReadCreatedAsync( string siteUrl, string listUrl, IReadOnlyList ids, CancellationToken cancellationToken) { var created = new Dictionary(); foreach (var chunk in ids.Chunk(IdChunkSize)) { var query = "$select=Id,Created&$top=5000&$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}")); var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listUrl, query, cancellationToken); foreach (var item in items) { if (!item.TryGetProperty("Id", out var idValue) || !idValue.TryGetInt32(out var id)) continue; if (!item.TryGetProperty("Created", out var createdValue)) continue; if (createdValue.ValueKind != JsonValueKind.String) continue; if (DateTimeOffset.TryParse(createdValue.GetString(), out var parsed)) created[id] = parsed; } } return created; } private static List Parse(IEnumerable items) => items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); }