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; namespace Tnsu.ApprovalRoute.Application.Approvals; /// /// Автосогласование по SLA: находит строки списков согласования, у которых /// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и /// принимает за согласующего положительное решение. /// /// Это ЗЕРКАЛО клиентской логики решения из request-portal /// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts — /// setDecision/activateNextQueued): та же последовательность «пометить строку → /// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером /// от имени приложения, а не браузером от имени человека. Расхождение между ними /// означало бы, что автосогласованная заявка встаёт в состояние, в которое /// портал её сам никогда не приводит, поэтому правила здесь повторены буквально, /// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus). /// /// В отличие от (срез «кто просрочил», /// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь /// последовательный: параллелить запись по типам заявок — это упереться в /// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск /// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun). /// public sealed class AutoApproveService( ApprovalRouteDbContext db, ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, TimeProvider timeProvider, IOptions options, ILogger logger) { // Активные строки С ВКЛЮЧЁННЫМ автосогласованием — отбор целиком на стороне // SharePoint, чтобы не тащить сюда весь список ради единиц строк. // // Да/Нет-колонка в $filter сравнивается с 1/0, а не с true/false. // // У строк, заведённых ДО появления колонки AutoApprove, значение пустое, и // под `eq 1` они не попадают — это верно по смыслу: включить автосогласование // задним числом нельзя, флаг проставляется при отправке заявки (портал) или // вручную в админке. private const string CandidateFilter = $"$filter=(ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}')" + " and AutoApprove eq 1"; private const string RowSelect = "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,AutoApprove,StepOrder,TicketId&$top=5000"; /// /// Один проход по всем типам заявок. Возвращает число автосогласованных строк — /// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из /// дашборда Hangfire, делал ли запуск хоть что-нибудь. /// public async Task RunAsync(CancellationToken cancellationToken) { var now = timeProvider.GetUtcNow(); // В отличие от напоминаний, ТИПЫ БЕЗ SlaHours отсюда НЕ отсеиваются: у типа // SLA может быть не задан, а у конкретного шага — задан, и именно такой шаг // чаще всего и делают автосогласуемым («это согласование формальное, ждём // сутки и идём дальше»). Шаг, у которого SLA нет ни там, ни там, отсеется // ниже поштучно (см. DeadlineOf). var types = await db.RequestTypes.AsNoTracking() .Where(x => x.ListId != null && x.SiteUrl != null) .ToListAsync(cancellationToken); var approved = 0; foreach (var type in types) { if (approved >= options.Value.MaxRowsPerRun) break; try { approved += await RunForTypeAsync(type, now, options.Value.MaxRowsPerRun - approved, cancellationToken); } catch (Exception e) { // Недоступный список одного типа не должен останавливать остальные — // ровно как в напоминаниях. logger.LogError(e, "Автосогласование по типу заявки «{Type}» не выполнено", type.Name); } } return approved; } private async Task RunForTypeAsync( RequestType type, DateTimeOffset now, int limit, CancellationToken cancellationToken) { var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); // Архив обходится наравне с текущей партицией: заявка, уехавшая в архив, // продолжает ждать решения так же, как в текущем списке, и её шаг точно так // же просрочивается (см. тот же довод в ReminderService). var partitions = archived .Select(x => (x.ListId, x.Number)) .Append((current.ListId, current.Number)); var approved = 0; foreach (var (listId, number) in partitions) { if (approved >= limit) break; try { approved += await RunForPartitionAsync(type, listId, now, limit - approved, cancellationToken); } catch (Exception e) { logger.LogError( e, "Автосогласование по списку {ListId} (партиция №{Number}) типа «{Type}» не выполнено", listId, number, type.Name); } } return approved; } private async Task RunForPartitionAsync( RequestType type, string listId, DateTimeOffset now, int limit, CancellationToken cancellationToken) { var siteUrl = type.SiteUrl!; var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); if (string.IsNullOrWhiteSpace(dataListTitle)) return 0; var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle); if (approvalListTitle is null) { logger.LogWarning( "Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти", dataListTitle); return 0; } var candidates = await ReadRowsAsync( siteUrl, approvalListTitle, $"{RowSelect}&{CandidateFilter}", cancellationToken); if (candidates.Count == 0) return 0; var approved = 0; // По заявкам, а не по строкам подряд: решение по одной строке двигает // очередь и меняет статус всей заявки, поэтому строки одной заявки нельзя // обрабатывать по устаревшему снимку — внутри группы состояние // перечитывается (см. ApproveAsync). foreach (var ticket in candidates.GroupBy(r => r.TicketId)) { if (approved >= limit) return approved; foreach (var candidate in ticket.OrderBy(r => r.Order)) { if (approved >= limit) return approved; var deadline = DeadlineOf(candidate, type); if (deadline is null || deadline > now) continue; var done = await ApproveAsync( type, siteUrl, approvalListTitle, listId, candidate, deadline.Value, now, cancellationToken); if (done) approved++; } } return approved; } /// /// Момент, после которого шаг считается просроченным. null — SLA не задан ни на /// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не /// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а /// сработал бы в произвольный момент после того, как SLA всё-таки настроят. /// private DateTimeOffset? DeadlineOf(ApprovalRow row, RequestType type) { if (row.Date is not { } activatedAt) { logger.LogWarning( "Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " + "но дата активации (Date) не заполнена — пропущена", row.Id, row.TicketId, type.Id); return null; } // Как и в напоминаниях: SLA строки перекрывает SLA типа заявки, и ноль — // это ноль («сразу»), а не «не задано». var slaHours = row.SlaHours ?? type.SlaHours; if (slaHours is null) { logger.LogWarning( "Строка {Row} заявки {Ticket} (requestTypeId={TypeId}) помечена на автосогласование, " + "но SLA не задан ни на шаге, ни на типе заявки — пропущена", row.Id, row.TicketId, type.Id); return null; } return activatedAt.AddHours(slaHours.Value); } /// /// Решение по одной строке. Возвращает false, если строку решать уже не нужно — /// её успел закрыть человек между выборкой кандидатов и этим моментом. /// /// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по /// кандидату из выборки: между ними проходит сколько угодно времени (обход /// партиций, сеть), и записать «Согласовано» поверх только что поставленного /// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью /// гонку это не закрывает (решение может прийти и в миллисекунду между /// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего /// прохода; блокировок на элемент списка SharePoint не предоставляет. /// private async Task ApproveAsync( RequestType type, string siteUrl, string approvalListTitle, string dataListUrl, ApprovalRow candidate, DateTimeOffset deadline, DateTimeOffset now, CancellationToken cancellationToken) { var rows = await ReadTicketRowsAsync(siteUrl, approvalListTitle, candidate.TicketId, cancellationToken); var row = rows.FirstOrDefault(r => r.Id == candidate.Id); if (row is null || !IsActive(row)) { logger.LogInformation( "Строка {Row} заявки {Ticket} уже не ждёт решения — автосогласование не требуется", candidate.Id, candidate.TicketId); return false; } // Фаза исполнения закрывается статусом «Выполнено», а не «Согласовано» — // это те же два значения, что ставит DecisionPanel портала для решений // approve и execute. var decision = row.Phase == ApprovalPhases.Execution ? ApprovalStatuses.Executed : ApprovalStatuses.Approved; await sharePoint.UpdateItemByListTitleAsync( siteUrl, approvalListTitle, row.Id, new { ApprovalStatus = decision, // Дата РЕШЕНИЯ перезаписывает дату активации — так же, как // setDecision в портале (колонка Date у строки одна). Date = now.UtcDateTime, Comment = options.Value.Comment }, cancellationToken); logger.LogInformation( "Строка {Row} заявки {Ticket} типа «{Type}» автосогласована ({Decision}): срок SLA истёк {Deadline:u}", row.Id, row.TicketId, type.Name, decision, deadline); // Дальше считаем по снимку, в котором решение УЖЕ применено: перечитывать // список после каждой записи незачем — мы сами знаем, что изменили, а // лишние чтения на каждом шаге заметно удлиняют проход. var updated = rows .Select(r => r.Id == row.Id ? r with { Status = decision } : r) .ToList(); var activated = ActivationTargets(updated); foreach (var target in activated) { await sharePoint.UpdateItemByListTitleAsync( siteUrl, approvalListTitle, target.Row.Id, // Активация — это статус ПЛЮС отметка времени «получил на // согласование»: от неё отсчитывается SLA следующего шага (см. // setActive в портале). Без даты шаг выпал бы и из напоминаний, и // из автосогласования. new { ApprovalStatus = target.Status, Date = now.UtcDateTime }, cancellationToken); } foreach (var target in activated) updated[updated.FindIndex(r => r.Id == target.Row.Id)] = target.Row with { Status = target.Status }; await sharePoint.UpdateItemByListUrlAsync( siteUrl, dataListUrl, row.TicketId, new { RequestStatus = ComputeRequestStatus(updated) }, cancellationToken); return true; } private static bool IsActive(ApprovalRow row) => row.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution; /// /// Кого активировать после положительного решения — зеркало /// ApprovalListService.activateNextQueued из request-portal. /// /// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ /// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза /// исполнения активируется, только когда фаза согласования пройдена целиком; /// если согласование остановлено (отклонение/возврат), не активируется никто. /// private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList rows) { var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList(); var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList(); if (approvalRows.Any(r => r.Status is ApprovalStatuses.Rejected or ApprovalStatuses.Returned)) return []; var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved); var (phaseRows, activeStatus) = approvalDone ? (execRows, ApprovalStatuses.OnExecution) : (approvalRows, ApprovalStatuses.OnApproval); // В фазе уже есть активная строка — очередь двигать не нужно (так бывает при // параллельном шаге, где решение принято ещё не всеми). if (phaseRows.Any(r => r.Status == activeStatus)) return []; var queued = phaseRows.Where(r => r.Status == ApprovalStatuses.Queued).ToList(); if (queued.Count == 0) return []; var minOrder = queued.Min(r => r.Order); return queued.Where(r => r.Order == minOrder).Select(r => (r, activeStatus)).ToList(); } /// /// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из /// request-portal (approverDecision.service.ts). Порядок проверок важен и /// повторён дословно: отклонение и возврат старше незавершённого согласования, /// а «Согласовано» — финал только для маршрута без фазы исполнения. /// private static string ComputeRequestStatus(IReadOnlyList rows) { var approvalRows = rows.Where(r => r.Phase != ApprovalPhases.Execution).ToList(); var execRows = rows.Where(r => r.Phase == ApprovalPhases.Execution).ToList(); if (approvalRows.Any(r => r.Status == ApprovalStatuses.Rejected)) return RequestStatuses.Rejected; if (approvalRows.Any(r => r.Status == ApprovalStatuses.Returned)) return RequestStatuses.Returned; var approvalDone = approvalRows.Count == 0 || approvalRows.All(r => r.Status == ApprovalStatuses.Approved); if (!approvalDone) return RequestStatuses.OnApproval; if (execRows.Count == 0) return RequestStatuses.Approved; if (execRows.All(r => r.Status == ApprovalStatuses.Executed)) return RequestStatuses.Closed; return RequestStatuses.Execution; } private Task> ReadTicketRowsAsync( string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) => ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken); private async Task> ReadRowsAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken) { var items = await sharePoint.GetItemsByListTitleAsync(siteUrl, listTitle, odataQuery, cancellationToken); return items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); } }