Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveService.cs
T
gorbenko.pavel ad62ac96ac feat(автосогласование): флаг на шаге маршрута и фоновая задача по SLA
Шаг маршрута получает флаг «Автосогласование» (AutoApprove). Когда у активной
строки такого шага истекает SLA, часовая задача Hangfire принимает за
согласующего положительное решение: помечает строку, двигает очередь и
пересчитывает статус заявки — тем же набором правил, что и портал, когда решение
принимает человек (зеркало approverDecision.service.ts/decide).

Флаг не nullable, в отличие от SLA рядом: наследовать его неоткуда — у типа
заявки такого поля нет и быть не должно.

- ApprovalRow и константы статусов переехали в общий Approvals: их теперь читают
  и напоминания, и автосогласование, разъезжаться разбору одного и того же
  списка нельзя;
- SharePointClient научился MERGE'ить элементы (раньше умел только читать).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:08:41 +05:00

355 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Автосогласование по SLA: находит строки списков согласования, у которых
/// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и
/// принимает за согласующего положительное решение.
///
/// Это ЗЕРКАЛО клиентской логики решения из request-portal
/// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts —
/// setDecision/activateNextQueued): та же последовательность «пометить строку →
/// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером
/// от имени приложения, а не браузером от имени человека. Расхождение между ними
/// означало бы, что автосогласованная заявка встаёт в состояние, в которое
/// портал её сам никогда не приводит, поэтому правила здесь повторены буквально,
/// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus).
///
/// В отличие от <see cref="Reminders.ReminderService"/> (срез «кто просрочил»,
/// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь
/// последовательный: параллелить запись по типам заявок — это упереться в
/// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск
/// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun).
/// </summary>
public sealed class AutoApproveService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
TimeProvider timeProvider,
IOptions<AutoApproveOptions> options,
ILogger<AutoApproveService> 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";
/// <summary>
/// Один проход по всем типам заявок. Возвращает число автосогласованных строк —
/// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из
/// дашборда Hangfire, делал ли запуск хоть что-нибудь.
/// </summary>
public async Task<int> 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<int> 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<int> 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;
}
/// <summary>
/// Момент, после которого шаг считается просроченным. null — SLA не задан ни на
/// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не
/// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а
/// сработал бы в произвольный момент после того, как SLA всё-таки настроят.
/// </summary>
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);
}
/// <summary>
/// Решение по одной строке. Возвращает false, если строку решать уже не нужно —
/// её успел закрыть человек между выборкой кандидатов и этим моментом.
///
/// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по
/// кандидату из выборки: между ними проходит сколько угодно времени (обход
/// партиций, сеть), и записать «Согласовано» поверх только что поставленного
/// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью
/// гонку это не закрывает (решение может прийти и в миллисекунду между
/// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего
/// прохода; блокировок на элемент списка SharePoint не предоставляет.
/// </summary>
private async Task<bool> 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;
/// <summary>
/// Кого активировать после положительного решения — зеркало
/// ApprovalListService.activateNextQueued из request-portal.
///
/// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ
/// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза
/// исполнения активируется, только когда фаза согласования пройдена целиком;
/// если согласование остановлено (отклонение/возврат), не активируется никто.
/// </summary>
private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList<ApprovalRow> 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();
}
/// <summary>
/// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из
/// request-portal (approverDecision.service.ts). Порядок проверок важен и
/// повторён дословно: отклонение и возврат старше незавершённого согласования,
/// а «Согласовано» — финал только для маршрута без фазы исполнения.
/// </summary>
private static string ComputeRequestStatus(IReadOnlyList<ApprovalRow> 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<IReadOnlyList<ApprovalRow>> ReadTicketRowsAsync(
string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) =>
ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken);
private async Task<IReadOnlyList<ApprovalRow>> 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();
}
}