2b8fe77fac
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
355 lines
20 KiB
C#
355 lines
20 KiB
C#
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();
|
||
}
|
||
}
|