From ad62ac96ac2ef4f6726e8c32e0f9e3c6123abe4e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 11 Sep 2026 18:08:41 +0500 Subject: [PATCH] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D1=81=D0=BE=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5):=20?= =?UTF-8?q?=D1=84=D0=BB=D0=B0=D0=B3=20=D0=BD=D0=B0=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20=D0=BF=D0=BE=20SLA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг маршрута получает флаг «Автосогласование» (AutoApprove). Когда у активной строки такого шага истекает SLA, часовая задача Hangfire принимает за согласующего положительное решение: помечает строку, двигает очередь и пересчитывает статус заявки — тем же набором правил, что и портал, когда решение принимает человек (зеркало approverDecision.service.ts/decide). Флаг не nullable, в отличие от SLA рядом: наследовать его неоткуда — у типа заявки такого поля нет и быть не должно. - ApprovalRow и константы статусов переехали в общий Approvals: их теперь читают и напоминания, и автосогласование, разъезжаться разбору одного и того же списка нельзя; - SharePointClient научился MERGE'ить элементы (раньше умел только читать). Co-Authored-By: Claude Opus 5 --- backend/Tnsu.ApprovalRoute.Api/Program.cs | 25 ++ .../Tnsu.ApprovalRoute.Api/appsettings.json | 5 + .../ApplicationServiceCollectionExtensions.cs | 3 + .../Approvals/ApprovalListNaming.cs | 30 ++ .../{Reminders => Approvals}/ApprovalRow.cs | 60 ++- .../Approvals/AutoApproveJob.cs | 35 ++ .../Approvals/AutoApproveOptions.cs | 40 ++ .../Approvals/AutoApproveService.cs | 354 +++++++++++++++++ .../Reminders/ReminderService.cs | 15 +- .../TemplateRouteStepDto.cs | 15 +- .../TemplateRouteStepService.cs | 7 +- .../TemplateRoutes/ResolveTemplateRouteDto.cs | 10 +- .../TemplateRoutes/TemplateRouteService.cs | 3 +- .../Entities/TemplateRouteStep.cs | 14 + ...60911130101_AddStepAutoApprove.Designer.cs | 370 ++++++++++++++++++ .../20260911130101_AddStepAutoApprove.cs | 29 ++ .../ApprovalRouteDbContextModelSnapshot.cs | 4 + .../SharePoint/SharePointClient.cs | 53 ++- .../AutoApproveServiceTests.cs | 353 +++++++++++++++++ 19 files changed, 1400 insertions(+), 25 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalListNaming.cs rename backend/Tnsu.ApprovalRoute.Application/{Reminders => Approvals}/ApprovalRow.cs (58%) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveJob.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveOptions.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.Designer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/AutoApproveServiceTests.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 21beda3..2f2a8f8 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -6,12 +6,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Scalar.AspNetCore; using Serilog; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.Approvals; using Tnsu.ApprovalRoute.Application.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Reports; @@ -59,6 +61,7 @@ builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); builder.Services.Configure(builder.Configuration.GetSection("ListPartitioning")); builder.Services.Configure(builder.Configuration.GetSection("Reports")); +builder.Services.Configure(builder.Configuration.GetSection("AutoApprove")); builder.Services.AddHangfire(configuration => configuration .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) @@ -133,6 +136,28 @@ app.UseHangfireDashboard("/hangfire", new DashboardOptions ] }); +// Рекуррентная задача регистрируется ПОСЛЕ Build (нужен собранный контейнер) и +// на каждом старте: AddOrUpdate идемпотентен и заодно подхватывает изменившееся +// расписание из конфигурации — иначе задача навсегда осталась бы с тем cron, с +// которым её создали в первый раз. +var autoApprove = app.Services.GetRequiredService>().Value; +var recurringJobs = app.Services.GetRequiredService(); +if (autoApprove.Enabled) +{ + recurringJobs.AddOrUpdate( + AutoApproveJob.RecurringJobId, + job => job.RunAsync(CancellationToken.None), + autoApprove.Cron, + // Cron в настройке — в UTC, как и всё остальное время в сервисе; иначе + // расписание поехало бы при смене часового пояса машины. + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); +} +else +{ + // Выключили настройкой — снимаем задачу, а не оставляем её вхолостую крутиться. + recurringJobs.RemoveIfExists(AutoApproveJob.RecurringJobId); +} + app.MapControllers(); await app.InitAndRunAsync(); diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index d6fc1ee..00fcc77 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -24,6 +24,11 @@ "https://localhost:4321" ] }, + "AutoApprove": { + "Enabled": true, + "Cron": "0 * * * *", + "MaxRowsPerRun": 200 + }, "ListPartitioning": { "ItemCountThreshold": 4900, "StaleLockAfter": "00:30:00", diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 27a0740..8be9da0 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -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(); // Джоба резолвится Hangfire'ом из scope на каждое выполнение. services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalListNaming.cs b/backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalListNaming.cs new file mode 100644 index 0000000..2ef4073 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalListNaming.cs @@ -0,0 +1,30 @@ +namespace Tnsu.ApprovalRoute.Application.Approvals; + +/// +/// Конвенция имён списков портала заявок: у каждой пары списков имя данных и имя +/// согласования отличаются только суффиксом после длинного тире (см. +/// Get-NumberedListTitle в request-portal/provisioning/Provision-Lists.ps1 — +/// именно провижинер эти имена и ставит). +/// +/// Отдельным классом, а не приватным методом сервиса: правило нужно и +/// напоминаниям (ReminderService), и автосогласованию (AutoApproveService), и +/// разъехаться между ними оно не должно — переименование одного списка из пары +/// молча оставило бы один из сценариев без строк. +/// +internal static class ApprovalListNaming +{ + /// + /// «Заявка на приём 1 — данные» → «Заявка на приём 1 — согласование». + /// null — имя не по конвенции, список согласования по нему не найти. + /// + 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; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalRow.cs similarity index 58% rename from backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs rename to backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalRow.cs index e48365e..25fc183 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Approvals/ApprovalRow.cs @@ -1,12 +1,17 @@ using System.Globalization; using System.Text.Json; -namespace Tnsu.ApprovalRoute.Application.Reminders; +namespace Tnsu.ApprovalRoute.Application.Approvals; /// /// Строка списка «… — согласование», разобранная из ответа SharePoint. /// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts): /// именно он эти списки и создаёт. +/// +/// Общая для напоминаний (ReminderService) и автосогласования +/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и +/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что +/// просроченным шаг считается в одном месте и не считается в другом. /// 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; } + /// Дробное число из JSON — с теми же оговорками, что и . + 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 = "Исполнение"; } +/// +/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки). +/// Один в один с REQUEST_STATUS в request-portal +/// (shared/approval/models/statuses.ts) — он эти значения и пишет. +/// 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 = "Возвращена на доработку"; +} + +/// +/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»). +/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки +/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же +/// statuses.ts, где оба набора сведены). +/// +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 = "Закрыта"; } diff --git a/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveJob.cs b/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveJob.cs new file mode 100644 index 0000000..39b2399 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveJob.cs @@ -0,0 +1,35 @@ +using Hangfire; +using Microsoft.Extensions.Logging; + +namespace Tnsu.ApprovalRoute.Application.Approvals; + +/// +/// Рекуррентная задача автосогласования — тонкая обёртка над +/// : всё, что она добавляет сверху, — расписание, +/// защита от наложения запусков и одна строка в логе с итогом. +/// +/// Повторы отключены (Attempts = 0) намеренно, в отличие от +/// CreateNextListPartitionJob: там повтор нужен, потому что пропуск задачи ломает +/// сценарий целиком (список не создан — писать некуда). Здесь же следующий запуск +/// придёт сам, через час, и сделает ровно ту же работу с актуальным состоянием — +/// уже согласованные строки в выборку не попадут. Повторять упавший проход раньше +/// этого смысла нет, а висящие в очереди повторы только прятали бы настоящую +/// причину сбоя. +/// +[AutomaticRetry(Attempts = 0)] +[DisableConcurrentExecution(timeoutInSeconds: 5 * 60)] +public sealed class AutoApproveJob(AutoApproveService service, ILogger logger) +{ + /// + /// Идентификатор рекуррентной задачи в Hangfire. Константой — по нему задача и + /// заводится, и снимается при выключении настройки (см. Program.cs): разъехавшись, + /// эти два имени оставили бы в дашборде задачу, которую уже некому снять. + /// + public const string RecurringJobId = "auto-approve"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + var approved = await service.RunAsync(cancellationToken); + logger.LogInformation("Автосогласование: строк согласовано автоматически — {Count}", approved); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveOptions.cs new file mode 100644 index 0000000..8e87eb6 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveOptions.cs @@ -0,0 +1,40 @@ +namespace Tnsu.ApprovalRoute.Application.Approvals; + +public sealed class AutoApproveOptions +{ + /// + /// Включено ли автосогласование вообще. Выключение снимает рекуррентную задачу + /// из Hangfire (см. Program.cs), а не просто делает её пустой: иначе в дашборде + /// осталась бы задача, которая регулярно запускается и ничего не делает. + /// + public bool Enabled { get; set; } = true; + + /// + /// Расписание запуска (cron, UTC). Раз в час: SLA задаётся в ЧАСАХ, поэтому + /// час — предельная точность, которая вообще имеет смысл, а более частый опрос + /// всех списков SharePoint ничего не ускоряет по существу. + /// + /// Следствие: шаг согласуется не в сам момент истечения SLA, а на ближайшем + /// запуске после него — то есть в пределах часа позже. Это осознанно: раньше + /// срока не согласуется никогда, а опоздание в пределах часа для срока, + /// измеряемого часами, роли не играет. + /// + public string Cron { get; set; } = "0 * * * *"; + + /// + /// Комментарий, который задача пишет в строку согласования вместо комментария + /// согласующего. Нужен, чтобы в матрице заявки было видно, что решение принял не + /// человек: других признаков у автосогласованной строки нет — статус и дата у неё + /// такие же, как у обычной. + /// + public string Comment { get; set; } = "Согласовано автоматически: истёк срок SLA по этому шагу."; + + /// + /// Сколько строк автосогласовать максимум за один запуск. Предохранитель на + /// случай массового включения флага или сдвига дат: без него один запуск мог бы + /// пройти по всему архиву партиций и написать в SharePoint тысячи изменений, + /// упёршись в троттлинг. Остаток уедет в следующий запуск — задача идемпотентна + /// (уже решённые строки в выборку активных не попадают). + /// + public int MaxRowsPerRun { get; set; } = 200; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveService.cs b/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveService.cs new file mode 100644 index 0000000..d9d3fed --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Approvals/AutoApproveService.cs @@ -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; + +/// +/// Автосогласование по SLA: находит строки списков согласования, у которых +/// включён флаг «Автосогласование» (колонка AutoApprove) и истёк срок SLA, и +/// принимает за согласующего положительное решение. +/// +/// Это ЗЕРКАЛО клиентской логики решения из request-portal +/// (shared/approval/approverDecision.service.ts — decide, и approvalList.service.ts — +/// setDecision/activateNextQueued): та же последовательность «пометить строку → +/// продвинуть очередь → пересчитать статус заявки», просто выполненная сервером +/// от имени приложения, а не браузером от имени человека. Расхождение между ними +/// означало бы, что автосогласованная заявка встаёт в состояние, в которое +/// портал её сам никогда не приводит, поэтому правила здесь повторены буквально, +/// а не «по смыслу» (см. ActivationTargets и ComputeRequestStatus). +/// +/// В отличие от (срез «кто просрочил», +/// без записи) задача именно ПИШЕТ в SharePoint, поэтому обход здесь +/// последовательный: параллелить запись по типам заявок — это упереться в +/// троттлинг и потерять предсказуемость порядка, а объём работы за один запуск +/// ограничен сверху (см. AutoApproveOptions.MaxRowsPerRun). +/// +public sealed class AutoApproveService( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + ListPartitionRegistry partitionRegistry, + TimeProvider timeProvider, + IOptions options, + ILogger 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"; + + /// + /// Один проход по всем типам заявок. Возвращает число автосогласованных строк — + /// его пишет в лог задача (см. AutoApproveJob), и по нему же удобно понять из + /// дашборда Hangfire, делал ли запуск хоть что-нибудь. + /// + public async Task 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 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 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; + } + + /// + /// Момент, после которого шаг считается просроченным. null — SLA не задан ни на + /// строке, ни на типе заявки, либо не заполнена дата активации: отсчитывать не + /// от чего, и автосогласовать такой шаг нельзя — он «висел» бы бесконечно, а + /// сработал бы в произвольный момент после того, как SLA всё-таки настроят. + /// + 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); + } + + /// + /// Решение по одной строке. Возвращает false, если строку решать уже не нужно — + /// её успел закрыть человек между выборкой кандидатов и этим моментом. + /// + /// Проверка «строка всё ещё активна» делается по СВЕЖЕМУ чтению, а не по + /// кандидату из выборки: между ними проходит сколько угодно времени (обход + /// партиций, сеть), и записать «Согласовано» поверх только что поставленного + /// человеком «Отклонено» — худшее, что эта задача может сделать. Полностью + /// гонку это не закрывает (решение может прийти и в миллисекунду между + /// проверкой и записью), но сводит окно к одному HTTP-запросу вместо всего + /// прохода; блокировок на элемент списка SharePoint не предоставляет. + /// + private async Task 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; + + /// + /// Кого активировать после положительного решения — зеркало + /// ApprovalListService.activateNextQueued из request-portal. + /// + /// Среди ещё ждущих строк текущей фазы активируются те, что делят НАИМЕНЬШИЙ + /// StepOrder: обычно это одна строка, несколько — параллельный шаг. Фаза + /// исполнения активируется, только когда фаза согласования пройдена целиком; + /// если согласование остановлено (отклонение/возврат), не активируется никто. + /// + private static IReadOnlyList<(ApprovalRow Row, string Status)> ActivationTargets(IReadOnlyList 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(); + } + + /// + /// Статус ЗАЯВКИ по строкам маршрута — зеркало computeRequestStatus из + /// request-portal (approverDecision.service.ts). Порядок проверок важен и + /// повторён дословно: отклонение и возврат старше незавершённого согласования, + /// а «Согласовано» — финал только для маршрута без фазы исполнения. + /// + private static string ComputeRequestStatus(IReadOnlyList 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> ReadTicketRowsAsync( + string siteUrl, string approvalListTitle, int ticketId, CancellationToken cancellationToken) => + ReadRowsAsync(siteUrl, approvalListTitle, $"{RowSelect}&$filter=TicketId eq {ticketId}", cancellationToken); + + private async Task> 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(); + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index f38ffd4..73ff932 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -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"); - /// «Заявка на приём — данные» → «Заявка на приём — согласование». - 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"; diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index eb41027..cb690ad 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -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? 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? Conditions, ConditionsLogic ConditionsLogic, int? SlaHours = null, - bool? AskOverdueReason = null); + bool? AskOverdueReason = null, + bool AutoApprove = false); /// /// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить @@ -68,4 +74,5 @@ public sealed record PatchTemplateRouteStepRequest( IReadOnlyList? Conditions, ConditionsLogic? ConditionsLogic = null, int? SlaHours = null, - bool? AskOverdueReason = null); + bool? AskOverdueReason = null, + bool? AutoApprove = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 2fdb185..d1d9d29 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -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); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 7632e7d..0b66c6b 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -20,6 +20,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; /// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить, /// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом. /// +/// +/// Согласовать шаг автоматически по истечении SLA. Значение шага маршрута как есть — +/// наследовать его неоткуда (у типа заявки такого поля нет, см. +/// TemplateRouteStep.AutoApprove). Вызывающая сторона (request-portal) просто +/// переносит его в строку списка согласования: решает по нему не она, а фоновая +/// задача бэкенда (AutoApproveService). +/// 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, diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index d49b082..657f00d 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -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); } diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 0478b4d..d343261 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -31,6 +31,20 @@ public class TemplateRouteStep /// public bool? AskOverdueReason { get; set; } + /// + /// Согласовывать шаг автоматически, когда истёк его SLA (см. ). + /// + /// Не nullable, в отличие от SLA-полей рядом: наследовать тут нечего — у типа + /// заявки такого «значения по умолчанию» нет и быть не должно (включить + /// автосогласование сразу всем шагам типа — не настройка, а авария), поэтому + /// три состояния не нужны, хватает обычного «включено/выключено». + /// + /// Действует только вместе с SLA: без действующего SLA (ни на шаге, ни на типе + /// заявки) отсчитывать нечего, и фоновая задача такой шаг пропускает + /// (см. AutoApproveService). + /// + public bool AutoApprove { get; set; } + /// Как объединяются между собой — не действует, если условий меньше двух. public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And; diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.Designer.cs new file mode 100644 index 0000000..6f00cb4 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.Designer.cs @@ -0,0 +1,370 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ListId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("ListPartitioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_partitioning_started_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("AutoApprove") + .HasColumnType("boolean") + .HasColumnName("auto_approve"); + + b.Property("ConditionsLogic") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("conditions_logic"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("StepId") + .HasColumnType("integer") + .HasColumnName("step_id"); + + b.Property("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("OptionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("option_name"); + + b.Property("FileData") + .HasColumnType("bytea") + .HasColumnName("file_data"); + + b.Property("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 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.cs new file mode 100644 index 0000000..22cf140 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260911130101_AddStepAutoApprove.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddStepAutoApprove : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "auto_approve", + table: "template_route_steps", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "auto_approve", + table: "template_route_steps"); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 520d7a5..da9803b 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -175,6 +175,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("ask_overdue_reason"); + b.Property("AutoApprove") + .HasColumnType("boolean") + .HasColumnName("auto_approve"); + b.Property("ConditionsLogic") .IsRequired() .HasMaxLength(16) diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs index e41187b..5574f75 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -53,6 +53,18 @@ public interface ISharePointClient /// Элементы списка, найденного по заголовку (списки согласования ищутся именно так). Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); + + /// + /// Обновить поля элемента списка, найденного по заголовку. fields — анонимный + /// объект «внутреннее имя колонки → значение»; перечисленные колонки + /// перезаписываются, остальные остаются как были. + /// + Task UpdateItemByListTitleAsync( + string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken); + + /// То же по серверному относительному url списка (см. RequestType.ListId). + Task UpdateItemByListUrlAsync( + string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken); } /// @@ -208,6 +220,38 @@ public sealed class SharePointClient( $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", 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); + + /// + /// Частичное обновление элемента: у SharePoint REST это POST с заголовком + /// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match. + /// + /// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не + /// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по + /// отдельным колонкам, а единственный конкурент — человек, принявший решение + /// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки + /// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать + /// отдельным запросом, и он всё равно устаревал бы к моменту записи. + /// + 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> GetItemsAsync( string siteUrl, string url, CancellationToken cancellationToken) { @@ -240,9 +284,16 @@ public sealed class SharePointClient( string url, string? body, bool allowNotFound, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool merge = false) { 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( "Bearer", await GetTokenAsync(siteUrl, cancellationToken)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/AutoApproveServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/AutoApproveServiceTests.cs new file mode 100644 index 0000000..43460c4 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/AutoApproveServiceTests.cs @@ -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; + +/// +/// Автосогласование по SLA. SharePoint подменён моком, отдающим сырой JSON в том +/// же виде, что и REST, — как в ReminderServiceTests; записи (MERGE) проверяются +/// через Verify на моке, потому что именно записи здесь и есть результат работы +/// сервиса, а возвращает он только счётчик. +/// +/// Все проверки идут через — маленький «список в памяти»: мок +/// отдаёт из него и выборку кандидатов, и строки заявки, и применяет к нему +/// записи. Без этого тесты, где решение по одной строке двигает очередь, пришлось +/// бы писать на жёстко заданных последовательностях ответов, и они проверяли бы +/// порядок вызовов, а не поведение. +/// +public sealed class AutoApproveServiceTests : IClassFixture +{ + 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(); + } + + /// + /// База у тестов класса общая, а сервис обходит ВСЕ типы заявок — поэтому у + /// каждого теста свой список: типы соседних тестов на мок не настроены, + /// заголовок списка для них не найдётся, и в выборку они не попадут. + /// + private async Task 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; + } + + /// Строка списка согласования в том виде, в каком её отдаёт REST. + 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" } + }); + } + + /// Записанные сервисом поля: (id элемента, имя колонки) → значение. + private sealed class Writes + { + public List<(string List, int Id, Dictionary Fields)> Items { get; } = []; + + public Dictionary? For(string list, int id) => + Items.LastOrDefault(x => x.List == list && x.Id == id).Fields; + } + + private (AutoApproveService Service, Writes Writes) BuildService( + string listUrl, List rows, DateTimeOffset? now = null) + { + var sharePoint = new Mock(); + var writes = new Writes(); + + sharePoint + .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) + .ReturnsAsync(DataListTitle); + + // Кандидаты: активные строки с включённым автосогласованием — тот же отбор, + // что делает $filter на стороне SharePoint. + sharePoint + .Setup(x => x.GetItemsByListTitleAsync( + SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("AutoApprove eq 1")), + It.IsAny())) + .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(q => q.Contains("TicketId eq")), + It.IsAny())) + .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(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny())) + .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(), + sharePoint.Object, + _services.GetRequiredService(), + new FixedTimeProvider(now ?? Now), + Options.Create(new AutoApproveOptions()), + NullLogger.Instance); + + return (service, writes); + } + + // Анонимный объект, который сервис отдаёт клиенту SharePoint, — в словарь: + // сравнивать поля по именам колонок нагляднее, чем городить ожидаемый + // анонимный тип той же формы. + private static Dictionary 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 + { + 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 + { + 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 + { + 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 + { + // По типу (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 + { + 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 { 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 + { + 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 + { + 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 + { + 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 + { + 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 + { + 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("Отклонено"); + } +}