Auto approve (#21)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
This commit was merged in pull request #21.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reports;
|
||||
@@ -20,6 +21,8 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddScoped<ListPartitionService>();
|
||||
// Джоба резолвится Hangfire'ом из scope на каждое выполнение.
|
||||
services.AddScoped<CreateNextListPartitionJob>();
|
||||
services.AddScoped<AutoApproveService>();
|
||||
services.AddScoped<AutoApproveJob>();
|
||||
services.AddScoped<VacancyReportService>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Конвенция имён списков портала заявок: у каждой пары списков имя данных и имя
|
||||
/// согласования отличаются только суффиксом после длинного тире (см.
|
||||
/// Get-NumberedListTitle в request-portal/provisioning/Provision-Lists.ps1 —
|
||||
/// именно провижинер эти имена и ставит).
|
||||
///
|
||||
/// Отдельным классом, а не приватным методом сервиса: правило нужно и
|
||||
/// напоминаниям (ReminderService), и автосогласованию (AutoApproveService), и
|
||||
/// разъехаться между ними оно не должно — переименование одного списка из пары
|
||||
/// молча оставило бы один из сценариев без строк.
|
||||
/// </summary>
|
||||
internal static class ApprovalListNaming
|
||||
{
|
||||
/// <summary>
|
||||
/// «Заявка на приём 1 — данные» → «Заявка на приём 1 — согласование».
|
||||
/// null — имя не по конвенции, список согласования по нему не найти.
|
||||
/// </summary>
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
+57
-3
@@ -1,12 +1,17 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||
/// именно он эти списки и создаёт.
|
||||
///
|
||||
/// Общая для напоминаний (ReminderService) и автосогласования
|
||||
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
|
||||
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
|
||||
/// просроченным шаг считается в одном месте и не считается в другом.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalRow(
|
||||
int Id,
|
||||
@@ -17,7 +22,13 @@ internal sealed record ApprovalRow(
|
||||
string? ApproverEmail,
|
||||
string? ApproverName,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason)
|
||||
bool? AskOverdueReason,
|
||||
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
|
||||
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
|
||||
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
|
||||
// сценария — см. RowSelect у каждого из них.
|
||||
double Order = 0,
|
||||
bool AutoApprove = false)
|
||||
{
|
||||
public static ApprovalRow? TryParse(JsonElement item)
|
||||
{
|
||||
@@ -41,7 +52,12 @@ internal sealed record ApprovalRow(
|
||||
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
|
||||
approverObject is { } an ? GetString(an, "Title") : null,
|
||||
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
|
||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null,
|
||||
// StepOrder — Number, и он бывает ДРОБНЫМ: делегат вклинивается между
|
||||
// шагами долей (см. ApproverService.delegate в request-portal),
|
||||
// поэтому здесь double, а не int.
|
||||
TryGetDouble(item, "StepOrder", out var order) ? order : 0,
|
||||
TryGetBool(item, "AutoApprove", out var auto) && auto);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
@@ -81,6 +97,18 @@ internal sealed record ApprovalRow(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Дробное число из JSON — с теми же оговорками, что и <see cref="TryGetInt"/>.</summary>
|
||||
private static bool TryGetDouble(JsonElement e, string name, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (!e.TryGetProperty(name, out var v)) return false;
|
||||
|
||||
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out value);
|
||||
|
||||
return v.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private static bool TryGetBool(JsonElement e, string name, out bool value)
|
||||
{
|
||||
value = false;
|
||||
@@ -110,8 +138,34 @@ internal static class ApprovalPhases
|
||||
public const string Execution = "Исполнение";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
|
||||
/// Один в один с REQUEST_STATUS в request-portal
|
||||
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
|
||||
/// </summary>
|
||||
internal static class ApprovalStatuses
|
||||
{
|
||||
public const string Queued = "Ждёт очереди";
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string OnExecution = "На исполнении";
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Executed = "Выполнено";
|
||||
public const string Rejected = "Отклонено";
|
||||
public const string Returned = "Возвращена на доработку";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»).
|
||||
/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки
|
||||
/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же
|
||||
/// statuses.ts, где оба набора сведены).
|
||||
/// </summary>
|
||||
internal static class RequestStatuses
|
||||
{
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Rejected = "Отклонено";
|
||||
public const string Returned = "Возвращена на доработку";
|
||||
public const string Execution = "Выполнение";
|
||||
public const string Closed = "Закрыта";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Рекуррентная задача автосогласования — тонкая обёртка над
|
||||
/// <see cref="AutoApproveService"/>: всё, что она добавляет сверху, — расписание,
|
||||
/// защита от наложения запусков и одна строка в логе с итогом.
|
||||
///
|
||||
/// Повторы отключены (Attempts = 0) намеренно, в отличие от
|
||||
/// CreateNextListPartitionJob: там повтор нужен, потому что пропуск задачи ломает
|
||||
/// сценарий целиком (список не создан — писать некуда). Здесь же следующий запуск
|
||||
/// придёт сам, через час, и сделает ровно ту же работу с актуальным состоянием —
|
||||
/// уже согласованные строки в выборку не попадут. Повторять упавший проход раньше
|
||||
/// этого смысла нет, а висящие в очереди повторы только прятали бы настоящую
|
||||
/// причину сбоя.
|
||||
/// </summary>
|
||||
[AutomaticRetry(Attempts = 0)]
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 5 * 60)]
|
||||
public sealed class AutoApproveJob(AutoApproveService service, ILogger<AutoApproveJob> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Идентификатор рекуррентной задачи в Hangfire. Константой — по нему задача и
|
||||
/// заводится, и снимается при выключении настройки (см. Program.cs): разъехавшись,
|
||||
/// эти два имени оставили бы в дашборде задачу, которую уже некому снять.
|
||||
/// </summary>
|
||||
public const string RecurringJobId = "auto-approve";
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var approved = await service.RunAsync(cancellationToken);
|
||||
logger.LogInformation("Автосогласование: строк согласовано автоматически — {Count}", approved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
public sealed class AutoApproveOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Включено ли автосогласование вообще. Выключение снимает рекуррентную задачу
|
||||
/// из Hangfire (см. Program.cs), а не просто делает её пустой: иначе в дашборде
|
||||
/// осталась бы задача, которая регулярно запускается и ничего не делает.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Расписание запуска (cron, UTC). Раз в час: SLA задаётся в ЧАСАХ, поэтому
|
||||
/// час — предельная точность, которая вообще имеет смысл, а более частый опрос
|
||||
/// всех списков SharePoint ничего не ускоряет по существу.
|
||||
///
|
||||
/// Следствие: шаг согласуется не в сам момент истечения SLA, а на ближайшем
|
||||
/// запуске после него — то есть в пределах часа позже. Это осознанно: раньше
|
||||
/// срока не согласуется никогда, а опоздание в пределах часа для срока,
|
||||
/// измеряемого часами, роли не играет.
|
||||
/// </summary>
|
||||
public string Cron { get; set; } = "0 * * * *";
|
||||
|
||||
/// <summary>
|
||||
/// Комментарий, который задача пишет в строку согласования вместо комментария
|
||||
/// согласующего. Нужен, чтобы в матрице заявки было видно, что решение принял не
|
||||
/// человек: других признаков у автосогласованной строки нет — статус и дата у неё
|
||||
/// такие же, как у обычной.
|
||||
/// </summary>
|
||||
public string Comment { get; set; } = "Согласовано автоматически: истёк срок SLA по этому шагу.";
|
||||
|
||||
/// <summary>
|
||||
/// Сколько строк автосогласовать максимум за один запуск. Предохранитель на
|
||||
/// случай массового включения флага или сдвига дат: без него один запуск мог бы
|
||||
/// пройти по всему архиву партиций и написать в SharePoint тысячи изменений,
|
||||
/// упёршись в троттлинг. Остаток уедет в следующий запуск — задача идемпотентна
|
||||
/// (уже решённые строки в выборку активных не попадают).
|
||||
/// </summary>
|
||||
public int MaxRowsPerRun { get; set; } = 200;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Application.Approvals;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
@@ -163,7 +164,7 @@ public sealed class ReminderService(
|
||||
return [];
|
||||
}
|
||||
|
||||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||||
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
|
||||
if (approvalListTitle is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
@@ -258,18 +259,6 @@ public sealed class ReminderService(
|
||||
.Replace("{listId}", Uri.EscapeDataString(listId))
|
||||
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
|
||||
|
||||
/// <summary>«Заявка на приём — данные» → «Заявка на приём — согласование».</summary>
|
||||
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,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
|
||||
|
||||
@@ -2,6 +2,9 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
|
||||
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
|
||||
//
|
||||
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
||||
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
||||
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||
@@ -24,7 +27,8 @@ public sealed record TemplateRouteStepDto(
|
||||
ConditionsLogic ConditionsLogic,
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
public sealed record CreateTemplateRouteStepRequest(
|
||||
int OrderNo,
|
||||
@@ -37,7 +41,8 @@ public sealed record CreateTemplateRouteStepRequest(
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
public sealed record UpdateTemplateRouteStepRequest(
|
||||
int OrderNo,
|
||||
@@ -50,7 +55,8 @@ public sealed record UpdateTemplateRouteStepRequest(
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
/// <summary>
|
||||
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
|
||||
@@ -68,4 +74,5 @@ public sealed record PatchTemplateRouteStepRequest(
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic? ConditionsLogic = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
bool? AutoApprove = null);
|
||||
|
||||
+5
-2
@@ -97,7 +97,8 @@ public sealed class TemplateRouteStepService(
|
||||
ConditionsLogic = r.ConditionsLogic,
|
||||
Conditions = ToEntities(r.Conditions),
|
||||
SlaHours = r.SlaHours,
|
||||
AskOverdueReason = r.AskOverdueReason
|
||||
AskOverdueReason = r.AskOverdueReason,
|
||||
AutoApprove = r.AutoApprove
|
||||
};
|
||||
db.TemplateRouteSteps.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -153,6 +154,7 @@ public sealed class TemplateRouteStepService(
|
||||
ReplaceConditions(entity, r.Conditions);
|
||||
entity.SlaHours = r.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason;
|
||||
entity.AutoApprove = r.AutoApprove;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||
@@ -224,6 +226,7 @@ public sealed class TemplateRouteStepService(
|
||||
// наследованию SLA от типа заявки можно через PUT.
|
||||
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
||||
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
||||
@@ -366,5 +369,5 @@ public sealed class TemplateRouteStepService(
|
||||
|
||||
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
||||
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason);
|
||||
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason, e.AutoApprove);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
|
||||
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
|
||||
/// </param>
|
||||
/// <param name="AutoApprove">
|
||||
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
|
||||
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
|
||||
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
|
||||
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
|
||||
/// задача бэкенда (AutoApproveService).
|
||||
/// </param>
|
||||
public sealed record ResolvedRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
@@ -29,7 +36,8 @@ public sealed record ResolvedRouteStepDto(
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null,
|
||||
HeadType? PositionCode = null);
|
||||
HeadType? PositionCode = null,
|
||||
bool AutoApprove = false);
|
||||
|
||||
public sealed record ResolveTemplateRouteResultDto(
|
||||
int RouteId,
|
||||
|
||||
@@ -385,7 +385,8 @@ public sealed class TemplateRouteService(
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
||||
step.HeadType is null or HeadType.None ? null : step.HeadType,
|
||||
step.AutoApprove);
|
||||
|
||||
return new ResolvedStepRow(dto, resolvedId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user