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:
gorbenko.pavel
2026-09-11 18:08:41 +05:00
parent e2b6227015
commit ad62ac96ac
19 changed files with 1400 additions and 25 deletions
+25
View File
@@ -6,12 +6,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.Reports; using Tnsu.ApprovalRoute.Application.Reports;
@@ -59,6 +61,7 @@ builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders")); builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning")); builder.Services.Configure<ListPartitioningOptions>(builder.Configuration.GetSection("ListPartitioning"));
builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports")); builder.Services.Configure<ReportsOptions>(builder.Configuration.GetSection("Reports"));
builder.Services.Configure<AutoApproveOptions>(builder.Configuration.GetSection("AutoApprove"));
builder.Services.AddHangfire(configuration => configuration builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
@@ -133,6 +136,28 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions
] ]
}); });
// Рекуррентная задача регистрируется ПОСЛЕ Build (нужен собранный контейнер) и
// на каждом старте: AddOrUpdate идемпотентен и заодно подхватывает изменившееся
// расписание из конфигурации — иначе задача навсегда осталась бы с тем cron, с
// которым её создали в первый раз.
var autoApprove = app.Services.GetRequiredService<IOptions<AutoApproveOptions>>().Value;
var recurringJobs = app.Services.GetRequiredService<IRecurringJobManager>();
if (autoApprove.Enabled)
{
recurringJobs.AddOrUpdate<AutoApproveJob>(
AutoApproveJob.RecurringJobId,
job => job.RunAsync(CancellationToken.None),
autoApprove.Cron,
// Cron в настройке — в UTC, как и всё остальное время в сервисе; иначе
// расписание поехало бы при смене часового пояса машины.
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
else
{
// Выключили настройкой — снимаем задачу, а не оставляем её вхолостую крутиться.
recurringJobs.RemoveIfExists(AutoApproveJob.RecurringJobId);
}
app.MapControllers(); app.MapControllers();
await app.InitAndRunAsync(); await app.InitAndRunAsync();
@@ -24,6 +24,11 @@
"https://localhost:4321" "https://localhost:4321"
] ]
}, },
"AutoApprove": {
"Enabled": true,
"Cron": "0 * * * *",
"MaxRowsPerRun": 200
},
"ListPartitioning": { "ListPartitioning": {
"ItemCountThreshold": 4900, "ItemCountThreshold": 4900,
"StaleLockAfter": "00:30:00", "StaleLockAfter": "00:30:00",
@@ -1,6 +1,7 @@
using System.Reflection; using System.Reflection;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.Reports; using Tnsu.ApprovalRoute.Application.Reports;
@@ -20,6 +21,8 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<ListPartitionService>(); services.AddScoped<ListPartitionService>();
// Джоба резолвится Hangfire'ом из scope на каждое выполнение. // Джоба резолвится Hangfire'ом из scope на каждое выполнение.
services.AddScoped<CreateNextListPartitionJob>(); services.AddScoped<CreateNextListPartitionJob>();
services.AddScoped<AutoApproveService>();
services.AddScoped<AutoApproveJob>();
services.AddScoped<VacancyReportService>(); services.AddScoped<VacancyReportService>();
services.AddScoped<TemplateRouteService>(); services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>(); 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.Globalization;
using System.Text.Json; using System.Text.Json;
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Approvals;
/// <summary> /// <summary>
/// Строка списка «… — согласование», разобранная из ответа SharePoint. /// Строка списка «… — согласование», разобранная из ответа SharePoint.
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts): /// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
/// именно он эти списки и создаёт. /// именно он эти списки и создаёт.
///
/// Общая для напоминаний (ReminderService) и автосогласования
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
/// просроченным шаг считается в одном месте и не считается в другом.
/// </summary> /// </summary>
internal sealed record ApprovalRow( internal sealed record ApprovalRow(
int Id, int Id,
@@ -17,7 +22,13 @@ internal sealed record ApprovalRow(
string? ApproverEmail, string? ApproverEmail,
string? ApproverName, string? ApproverName,
int? SlaHours, int? SlaHours,
bool? AskOverdueReason) bool? AskOverdueReason,
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
// сценария — см. RowSelect у каждого из них.
double Order = 0,
bool AutoApprove = false)
{ {
public static ApprovalRow? TryParse(JsonElement item) public static ApprovalRow? TryParse(JsonElement item)
{ {
@@ -41,7 +52,12 @@ internal sealed record ApprovalRow(
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null. // системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
approverObject is { } an ? GetString(an, "Title") : null, approverObject is { } an ? GetString(an, "Title") : null,
TryGetInt(item, "SLAhours", out var sla) ? sla : 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) => private static string? GetString(JsonElement e, string name) =>
@@ -81,6 +97,18 @@ internal sealed record ApprovalRow(
return true; 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) private static bool TryGetBool(JsonElement e, string name, out bool value)
{ {
value = false; value = false;
@@ -110,8 +138,34 @@ internal static class ApprovalPhases
public const string Execution = "Исполнение"; public const string Execution = "Исполнение";
} }
/// <summary>
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
/// Один в один с REQUEST_STATUS в request-portal
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
/// </summary>
internal static class ApprovalStatuses internal static class ApprovalStatuses
{ {
public const string Queued = "Ждёт очереди";
public const string OnApproval = "На согласовании"; public const string OnApproval = "На согласовании";
public const string OnExecution = "На исполнении"; 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.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
@@ -163,7 +164,7 @@ public sealed class ReminderService(
return []; return [];
} }
var approvalListTitle = ApprovalListTitleFor(dataListTitle); var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null) if (approvalListTitle is null)
{ {
logger.LogWarning( logger.LogWarning(
@@ -258,18 +259,6 @@ public sealed class ReminderService(
.Replace("{listId}", Uri.EscapeDataString(listId)) .Replace("{listId}", Uri.EscapeDataString(listId))
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals"); .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 = private const string RowSelect =
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," + "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000"; "TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
@@ -2,6 +2,9 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
// AutoApprove — «согласовать шаг автоматически по истечении SLA». Обычный bool,
// а не три состояния: наследовать его неоткуда (см. TemplateRouteStep.AutoApprove).
//
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки. // SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны // null — «не задано, наследовать», поэтому оба поля nullable и необязательны
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец // (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
@@ -24,7 +27,8 @@ public sealed record TemplateRouteStepDto(
ConditionsLogic ConditionsLogic, ConditionsLogic ConditionsLogic,
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool AutoApprove = false);
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -37,7 +41,8 @@ public sealed record CreateTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic, ConditionsLogic ConditionsLogic,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool AutoApprove = false);
public sealed record UpdateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -50,7 +55,8 @@ public sealed record UpdateTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic, ConditionsLogic ConditionsLogic,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool AutoApprove = false);
/// <summary> /// <summary>
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить /// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
@@ -68,4 +74,5 @@ public sealed record PatchTemplateRouteStepRequest(
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic? ConditionsLogic = null, ConditionsLogic? ConditionsLogic = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
bool? AutoApprove = null);
@@ -97,7 +97,8 @@ public sealed class TemplateRouteStepService(
ConditionsLogic = r.ConditionsLogic, ConditionsLogic = r.ConditionsLogic,
Conditions = ToEntities(r.Conditions), Conditions = ToEntities(r.Conditions),
SlaHours = r.SlaHours, SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason AskOverdueReason = r.AskOverdueReason,
AutoApprove = r.AutoApprove
}; };
db.TemplateRouteSteps.Add(entity); db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -153,6 +154,7 @@ public sealed class TemplateRouteStepService(
ReplaceConditions(entity, r.Conditions); ReplaceConditions(entity, r.Conditions);
entity.SlaHours = r.SlaHours; entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason; entity.AskOverdueReason = r.AskOverdueReason;
entity.AutoApprove = r.AutoApprove;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
logger.LogInformation( logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
@@ -224,6 +226,7 @@ public sealed class TemplateRouteStepService(
// наследованию SLA от типа заявки можно через PUT. // наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours; entity.SlaHours = r.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason; entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
entity.AutoApprove = r.AutoApprove ?? entity.AutoApprove;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
logger.LogInformation( logger.LogInformation(
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email); "Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
@@ -366,5 +369,5 @@ public sealed class TemplateRouteStepService(
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( 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, 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 текстом. /// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
/// </param> /// </param>
/// <param name="AutoApprove">
/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть —
/// наследовать его неоткуда (у типа заявки такого поля нет, см.
/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто
/// переносит его в строку списка согласования: решает по нему не она, а фоновая
/// задача бэкенда (AutoApproveService).
/// </param>
public sealed record ResolvedRouteStepDto( public sealed record ResolvedRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
@@ -29,7 +36,8 @@ public sealed record ResolvedRouteStepDto(
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null, bool? AskOverdueReason = null,
HeadType? PositionCode = null); HeadType? PositionCode = null,
bool AutoApprove = false);
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
int RouteId, int RouteId,
@@ -385,7 +385,8 @@ public sealed class TemplateRouteService(
combinedWarning.Length == 0 ? null : combinedWarning, combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours, step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason, 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); return new ResolvedStepRow(dto, resolvedId);
} }
@@ -31,6 +31,20 @@ public class TemplateRouteStep
/// </summary> /// </summary>
public bool? AskOverdueReason { get; set; } public bool? AskOverdueReason { get; set; }
/// <summary>
/// Согласовывать шаг автоматически, когда истёк его SLA (см. <see cref="SlaHours"/>).
///
/// Не nullable, в отличие от SLA-полей рядом: наследовать тут нечего — у типа
/// заявки такого «значения по умолчанию» нет и быть не должно (включить
/// автосогласование сразу всем шагам типа — не настройка, а авария), поэтому
/// три состояния не нужны, хватает обычного «включено/выключено».
///
/// Действует только вместе с SLA: без действующего SLA (ни на шаге, ни на типе
/// заявки) отсчитывать нечего, и фоновая задача такой шаг пропускает
/// (см. AutoApproveService).
/// </summary>
public bool AutoApprove { get; set; }
/// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary> /// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And; public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
@@ -0,0 +1,370 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260911130101_AddStepAutoApprove")]
partial class AddStepAutoApprove
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("OptionName")
.HasName("pk_settings");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStepAutoApprove : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "auto_approve",
table: "template_route_steps",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "auto_approve",
table: "template_route_steps");
}
}
}
@@ -175,6 +175,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("ask_overdue_reason"); .HasColumnName("ask_overdue_reason");
b.Property<bool>("AutoApprove")
.HasColumnType("boolean")
.HasColumnName("auto_approve");
b.Property<string>("ConditionsLogic") b.Property<string>("ConditionsLogic")
.IsRequired() .IsRequired()
.HasMaxLength(16) .HasMaxLength(16)
@@ -53,6 +53,18 @@ public interface ISharePointClient
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary> /// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync( Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
/// <summary>
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
/// перезаписываются, остальные остаются как были.
/// </summary>
Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken);
/// <summary>То же по серверному относительному url списка (см. RequestType.ListId).</summary>
Task UpdateItemByListUrlAsync(
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken);
} }
/// <summary> /// <summary>
@@ -208,6 +220,38 @@ public sealed class SharePointClient(
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}",
cancellationToken); cancellationToken);
public Task UpdateItemByListTitleAsync(
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items({itemId})",
fields,
cancellationToken);
public Task UpdateItemByListUrlAsync(
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken) =>
MergeAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})",
fields,
cancellationToken);
/// <summary>
/// Частичное обновление элемента: у SharePoint REST это POST с заголовком
/// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match.
///
/// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не
/// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по
/// отдельным колонкам, а единственный конкурент — человек, принявший решение
/// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки
/// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать
/// отдельным запросом, и он всё равно устаревал бы к моменту записи.
/// </summary>
private Task MergeAsync(string siteUrl, string url, object fields, CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post, siteUrl, url, JsonSerializer.Serialize(fields),
allowNotFound: false, cancellationToken, merge: true);
private async Task<IReadOnlyList<JsonElement>> GetItemsAsync( private async Task<IReadOnlyList<JsonElement>> GetItemsAsync(
string siteUrl, string url, CancellationToken cancellationToken) string siteUrl, string url, CancellationToken cancellationToken)
{ {
@@ -240,9 +284,16 @@ public sealed class SharePointClient(
string url, string url,
string? body, string? body,
bool allowNotFound, bool allowNotFound,
CancellationToken cancellationToken) CancellationToken cancellationToken,
bool merge = false)
{ {
using var request = new HttpRequestMessage(method, url); using var request = new HttpRequestMessage(method, url);
if (merge)
{
request.Headers.Add("X-HTTP-Method", "MERGE");
request.Headers.Add("If-Match", "*");
}
request.Headers.Authorization = new AuthenticationHeaderValue( request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", await GetTokenAsync(siteUrl, cancellationToken)); "Bearer", await GetTokenAsync(siteUrl, cancellationToken));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
@@ -0,0 +1,353 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Автосогласование по SLA. SharePoint подменён моком, отдающим сырой JSON в том
/// же виде, что и REST, — как в ReminderServiceTests; записи (MERGE) проверяются
/// через Verify на моке, потому что именно записи здесь и есть результат работы
/// сервиса, а возвращает он только счётчик.
///
/// Все проверки идут через <see cref="Rows"/> — маленький «список в памяти»: мок
/// отдаёт из него и выборку кандидатов, и строки заявки, и применяет к нему
/// записи. Без этого тесты, где решение по одной строке двигает очередь, пришлось
/// бы писать на жёстко заданных последовательностях ответов, и они проверяли бы
/// порядок вызовов, а не поведение.
/// </summary>
public sealed class AutoApproveServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string DataListTitle = "Заявка авто — данные";
private const string ApprovalListTitle = "Заявка авто — согласование";
private static readonly DateTimeOffset Now = new(2026, 9, 11, 12, 0, 0, TimeSpan.Zero);
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService;
public AutoApproveServiceTests(PostgresDatabaseFixture fixture)
{
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
}
/// <summary>
/// База у тестов класса общая, а сервис обходит ВСЕ типы заявок — поэтому у
/// каждого теста свой список: типы соседних тестов на мок не настроены,
/// заголовок списка для них не найдётся, и в выборку они не попадут.
/// </summary>
private async Task<string> CreateTypeAsync(string name, int? slaHours = null)
{
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours),
CancellationToken.None);
return listUrl;
}
/// <summary>Строка списка согласования в том виде, в каком её отдаёт REST.</summary>
private sealed record Row(
int Id,
int TicketId,
string Status,
double Order,
string Phase = "Согласование",
string? Date = null,
int? SlaHours = null,
bool AutoApprove = false)
{
public JsonElement ToJson() => JsonSerializer.SerializeToElement(new
{
Id,
TicketId,
ApprovalStatus = Status,
Phase,
Date,
SLAhours = SlaHours,
AutoApprove,
StepOrder = Order,
TansuSiteApprover = new { EMail = "approver@tnsu.kz" }
});
}
/// <summary>Записанные сервисом поля: (id элемента, имя колонки) → значение.</summary>
private sealed class Writes
{
public List<(string List, int Id, Dictionary<string, object?> Fields)> Items { get; } = [];
public Dictionary<string, object?>? For(string list, int id) =>
Items.LastOrDefault(x => x.List == list && x.Id == id).Fields;
}
private (AutoApproveService Service, Writes Writes) BuildService(
string listUrl, List<Row> rows, DateTimeOffset? now = null)
{
var sharePoint = new Mock<ISharePointClient>();
var writes = new Writes();
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
// Кандидаты: активные строки с включённым автосогласованием — тот же отбор,
// что делает $filter на стороне SharePoint.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("AutoApprove eq 1")),
It.IsAny<CancellationToken>()))
.ReturnsAsync(() => rows
.Where(r => r.AutoApprove && r.Status is "На согласовании" or "На исполнении")
.Select(r => r.ToJson()).ToList());
// Строки одной заявки — $filter=TicketId eq N.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")),
It.IsAny<CancellationToken>()))
.ReturnsAsync((string _, string _, string query, CancellationToken _) =>
{
var ticketId = int.Parse(query.Split("TicketId eq ")[1].Split('&')[0].Trim());
return rows.Where(r => r.TicketId == ticketId).Select(r => r.ToJson()).ToList();
});
// Запись в строку маршрута применяется к самому списку rows — чтобы
// следующее чтение видело уже изменённое состояние, как в SharePoint.
sharePoint
.Setup(x => x.UpdateItemByListTitleAsync(
SiteUrl, ApprovalListTitle, It.IsAny<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Callback((string _, string list, int id, object fields, CancellationToken _) =>
{
var map = ToMap(fields);
writes.Items.Add((list, id, map));
var index = rows.FindIndex(r => r.Id == id);
if (index >= 0 && map.TryGetValue("ApprovalStatus", out var status))
rows[index] = rows[index] with { Status = (string)status! };
})
.Returns(Task.CompletedTask);
sharePoint
.Setup(x => x.UpdateItemByListUrlAsync(
SiteUrl, listUrl, It.IsAny<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.Callback((string _, string list, int id, object fields, CancellationToken _) =>
writes.Items.Add((list, id, ToMap(fields))))
.Returns(Task.CompletedTask);
var service = new AutoApproveService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
new FixedTimeProvider(now ?? Now),
Options.Create(new AutoApproveOptions()),
NullLogger<AutoApproveService>.Instance);
return (service, writes);
}
// Анонимный объект, который сервис отдаёт клиенту SharePoint, — в словарь:
// сравнивать поля по именам колонок нагляднее, чем городить ожидаемый
// анонимный тип той же формы.
private static Dictionary<string, object?> ToMap(object fields) =>
fields.GetType().GetProperties().ToDictionary(p => p.Name, p => p.GetValue(fields));
[Fact(DisplayName = "SLA истёк — шаг согласуется, очередь двигается, статус заявки пересчитан")]
public async Task Overdue_ApprovesAndAdvancesQueue()
{
var listUrl = await CreateTypeAsync("Auto basic", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(11, 1, "Ждёт очереди", 2)
};
var (service, writes) = BuildService(listUrl, rows);
var approved = await service.RunAsync(CancellationToken.None);
approved.ShouldBe(1);
var decided = writes.For(ApprovalListTitle, 10).ShouldNotBeNull();
decided["ApprovalStatus"].ShouldBe("Согласовано");
decided["Date"].ShouldBe(Now.UtcDateTime);
decided["Comment"].ShouldBe(new AutoApproveOptions().Comment);
// Следующий шаг активирован — со своей датой активации, иначе от него не
// отсчитать ни напоминание, ни собственное автосогласование.
var activated = writes.For(ApprovalListTitle, 11).ShouldNotBeNull();
activated["ApprovalStatus"].ShouldBe("На согласовании");
activated["Date"].ShouldBe(Now.UtcDateTime);
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании");
}
[Fact(DisplayName = "Срок ещё не вышел — строка не трогается")]
public async Task WithinSla_DoesNothing()
{
var listUrl = await CreateTypeAsync("Auto not due", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Флаг выключен — просроченный шаг остаётся ждать человека")]
public async Task WithoutFlag_DoesNothing()
{
var listUrl = await CreateTypeAsync("Auto flag off", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"))
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "SLA шага перекрывает SLA типа заявки")]
public async Task RowSla_OverridesTypeSla()
{
var listUrl = await CreateTypeAsync("Auto row sla", slaHours: 100);
var rows = new List<Row>
{
// По типу (100 ч) срок ещё не вышел, по шагу (1 ч) — вышел.
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-5).ToString("O"), SlaHours: 1, AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 10).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Согласовано");
}
[Fact(DisplayName = "SLA не задан нигде — шаг пропущен, а не согласован немедленно")]
public async Task WithoutSla_Skipped()
{
var listUrl = await CreateTypeAsync("Auto no sla");
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Дата активации не заполнена — отсчитывать не от чего, шаг пропущен")]
public async Task WithoutDate_Skipped()
{
var listUrl = await CreateTypeAsync("Auto no date", slaHours: 24);
var rows = new List<Row> { new(10, 1, "На согласовании", 1, AutoApprove: true) };
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(0);
writes.Items.ShouldBeEmpty();
}
[Fact(DisplayName = "Последний шаг согласования без исполнителей — заявка «Согласовано»")]
public async Task LastApprovalStep_ClosesRequestAsApproved()
{
var listUrl = await CreateTypeAsync("Auto last step", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Согласовано", 1),
new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Согласовано");
}
[Fact(DisplayName = "Согласование пройдено — активируется фаза исполнения, заявка «Выполнение»")]
public async Task LastApprovalStep_ActivatesExecutionPhase()
{
var listUrl = await CreateTypeAsync("Auto to execution", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(11, 1, "Ждёт очереди", 2, Phase: "Исполнение")
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("На исполнении");
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Выполнение");
}
[Fact(DisplayName = "Шаг исполнения закрывается как «Выполнено», заявка — «Закрыта»")]
public async Task ExecutionStep_MarkedExecuted()
{
var listUrl = await CreateTypeAsync("Auto execution", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Согласовано", 1),
new(11, 1, "На исполнении", 2, Phase: "Исполнение",
Date: Now.AddHours(-48).ToString("O"), AutoApprove: true)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Выполнено");
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Закрыта");
}
[Fact(DisplayName = "Параллельный шаг: решён только один — очередь не двигается")]
public async Task ParallelStep_DoesNotAdvanceWhileSiblingPending()
{
var listUrl = await CreateTypeAsync("Auto parallel", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
// Второй согласующий того же шага (общий StepOrder) решения ещё не принял.
new(11, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O")),
new(12, 1, "Ждёт очереди", 2)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 12).ShouldBeNull();
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании");
}
[Fact(DisplayName = "Шаг отклонён человеком — очередь не двигается, статус заявки «Отклонено»")]
public async Task RejectedSibling_StopsQueue()
{
var listUrl = await CreateTypeAsync("Auto rejected", slaHours: 24);
var rows = new List<Row>
{
new(10, 1, "Отклонено", 1),
new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true),
new(12, 1, "Ждёт очереди", 3)
};
var (service, writes) = BuildService(listUrl, rows);
(await service.RunAsync(CancellationToken.None)).ShouldBe(1);
writes.For(ApprovalListTitle, 12).ShouldBeNull();
writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Отклонено");
}
}