Auto approve (#21)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 3m39s

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:
2026-09-14 09:01:56 +00:00
parent e2b6227015
commit 2b8fe77fac
24 changed files with 1443 additions and 29 deletions
@@ -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;
}
}
@@ -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);
@@ -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);
}