feat(автосогласование): флаг на шаге маршрута и фоновая задача по SLA
Шаг маршрута получает флаг «Автосогласование» (AutoApprove). Когда у активной строки такого шага истекает SLA, часовая задача Hangfire принимает за согласующего положительное решение: помечает строку, двигает очередь и пересчитывает статус заявки — тем же набором правил, что и портал, когда решение принимает человек (зеркало approverDecision.service.ts/decide). Флаг не nullable, в отличие от SLA рядом: наследовать его неоткуда — у типа заявки такого поля нет и быть не должно. - ApprovalRow и константы статусов переехали в общий Approvals: их теперь читают и напоминания, и автосогласование, разъезжаться разбору одного и того же списка нельзя; - SharePointClient научился MERGE'ить элементы (раньше умел только читать). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user