From 9fc089c37aac010127047d42254169216777c216 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Wed, 12 Aug 2026 15:12:11 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 + .../Auth/AuthSchemes.cs | 24 ++ .../Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs | 144 +++++++-- .../Controllers/RemindersController.cs | 26 ++ backend/Tnsu.ApprovalRoute.Api/Program.cs | 2 + .../Tnsu.ApprovalRoute.Api/appsettings.json | 9 +- .../ApplicationServiceCollectionExtensions.cs | 2 + .../Reminders/ApprovalRow.cs | 95 ++++++ .../Reminders/ReminderDto.cs | 20 ++ .../Reminders/ReminderService.cs | 295 ++++++++++++++++++ .../Reminders/RemindersOptions.cs | 21 ++ .../RequestTypes/RequestTypeDto.cs | 41 ++- .../RequestTypes/RequestTypeService.cs | 16 +- .../RequestTypes/RequestTypeValidators.cs | 18 ++ .../TemplateRouteStepDto.cs | 21 +- .../TemplateRouteStepService.cs | 14 +- .../TemplateRouteStepValidators.cs | 12 + .../TemplateRoutes/ResolveTemplateRouteDto.cs | 15 +- .../TemplateRoutes/TemplateRouteService.cs | 27 +- .../Entities/RequestType.cs | 16 + .../Entities/TemplateRouteStep.cs | 15 + ...frastructureServiceCollectionExtensions.cs | 7 + .../Persistence/ApprovalRouteDbContext.cs | 12 + .../Persistence/Entities/AppSetting.cs | 24 ++ .../20260812091325_AddSlaSettings.Designer.cs | 254 +++++++++++++++ .../20260812091325_AddSlaSettings.cs | 74 +++++ .../ApprovalRouteDbContextModelSnapshot.cs | 37 +++ .../SharePoint/CertificateProvider.cs | 70 +++++ .../SharePoint/SharePointClient.cs | 237 ++++++++++++++ .../SharePoint/SharePointOptions.cs | 23 ++ .../Tnsu.ApprovalRoute.Infrastructure.csproj | 9 + .../ReminderServiceTests.cs | 285 +++++++++++++++++ .../TemplateRouteServiceTests.cs | 74 +++++ .../SharePointClientParsingTests.cs | 99 ++++++ .../screens/ManageRequestsPhaseTable.tsx | 14 + .../screens/ManageRequestsScreen.tsx | 45 ++- .../screens/ManageRequestsSlaModal.tsx | 84 +++++ .../components/screens/RequestTypesScreen.tsx | 46 ++- .../components/screens/RouteStepFormModal.tsx | 45 ++- .../approvalAdmin/models/requestManage.ts | 11 + .../approvalAdmin/models/requestTypes.ts | 12 + .../models/templateRouteSteps.ts | 11 + .../services/requestManageService.ts | 35 ++- frontend/src/webparts/approvalAdmin/texts.ts | 33 ++ .../webparts/approvalAdmin/utils/sla.test.ts | 86 +++++ .../src/webparts/approvalAdmin/utils/sla.ts | 70 +++++ .../approvalAdmin/utils/stepExecutor.test.ts | 2 + 47 files changed, 2481 insertions(+), 52 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Entities/AppSetting.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.Designer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/CertificateProvider.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsSlaModal.tsx create mode 100644 frontend/src/webparts/approvalAdmin/utils/sla.test.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/sla.ts diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 64c8bfc..0ee4fae 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -3,6 +3,7 @@ true + diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs index 331bf3d..27f8132 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs @@ -1,6 +1,30 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; + namespace Tnsu.ApprovalRoute.Api.Auth; public static class AuthSchemes { + /// Локальная разработка и тесты — Entra не настроен (см. AuthSetup). public const string Dev = "Dev"; + + /// Токен пользователя (делегированный) — обычная работа админки. + public const string Bearer = JwtBearerDefaults.AuthenticationScheme; + + /// + /// Токен приложения (client_credentials) — вызовы без участия пользователя, + /// например Power Automate, который по расписанию забирает напоминания. + /// + public const string AppOnly = "AppOnly"; +} + +public static class AuthPolicies +{ + /// Только пользовательские токены. + public const string Delegated = "Delegated"; + + /// Только токены приложений. + public const string AppOnly = "AppOnlyPolicy"; + + /// Любой из двух — политика по умолчанию для всех контроллеров. + public const string Any = "AnyAuth"; } diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs index e72b17c..8e0d770 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs @@ -6,36 +6,136 @@ namespace Tnsu.ApprovalRoute.Api.Auth; public static class AuthSetup { + /// + /// Две схемы аутентификации вместо одной: помимо пользовательского токена + /// (админка) API должен принимать токен ПРИЛОЖЕНИЯ — эндпоинт напоминаний + /// вызывает Power Automate по расписанию, там нет ни пользователя, ни + /// интерактивного входа. + /// + /// Сделано по образцу api-tnsu-portal/AuthRegistration.cs, включая настройки + /// Entra — приложение регистрируется то же самое. + /// public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration) { var tenantId = configuration["Entra:TenantId"]; var audience = configuration["Entra:Audience"]; if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience)) - { - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; - options.TokenValidationParameters = new TokenValidationParameters - { - ValidIssuers = - [ - $"https://login.microsoftonline.com/{tenantId}/v2.0", - $"https://sts.windows.net/{tenantId}/" - ], - ValidAudiences = [audience, $"api://{audience}"] - }; - }); - } + AddEntraAuth(services, configuration, tenantId, audience); else - { - services.AddAuthentication(AuthSchemes.Dev) - .AddScheme(AuthSchemes.Dev, _ => { }); - } - - services.AddAuthorization(); + AddDevAuth(services); return services; } + + private static void AddEntraAuth( + IServiceCollection services, IConfiguration configuration, string tenantId, string audience) + { + // Белый список приложений, которым разрешён app-only доступ. Пустой список + // означает «не ограничивать» — иначе на пустой конфигурации сломались бы + // вообще все app-only вызовы, что при выкладке выглядит как необъяснимый 401. + var allowedAppIds = configuration.GetSection("Entra:AllowedAppIds").Get() ?? []; + + var validationParameters = () => new TokenValidationParameters + { + ValidAudiences = [audience, $"api://{audience}"], + ValidIssuers = + [ + $"https://login.microsoftonline.com/{tenantId}/v2.0", + $"https://sts.windows.net/{tenantId}/" + ], + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true + }; + + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = AuthSchemes.Bearer; + options.DefaultChallengeScheme = AuthSchemes.Bearer; + }) + .AddJwtBearer(AuthSchemes.Bearer, options => + { + options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; + options.TokenValidationParameters = validationParameters(); + }); + + services.AddAuthentication() + .AddJwtBearer(AuthSchemes.AppOnly, options => + { + options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; + options.TokenValidationParameters = validationParameters(); + options.Events = new JwtBearerEvents + { + // Подпись, издатель и аудитория у app-only токена те же, что у + // пользовательского, поэтому единственное, что реально отличает + // «свой» вызывающий сервис от любого приложения тенанта, — appid. + OnTokenValidated = context => + { + var claims = context.Principal?.Claims; + var appId = claims?.FirstOrDefault(c => c.Type == "appid")?.Value + ?? claims?.FirstOrDefault(c => c.Type == "azp")?.Value; + + if (allowedAppIds.Length > 0 && !allowedAppIds.Contains(appId)) + { + context.HttpContext.RequestServices + .GetRequiredService() + .CreateLogger("Auth.AppOnly") + .LogWarning("Приложение «{AppId}» отклонено — его нет в Entra:AllowedAppIds", appId); + context.Fail($"Приложение «{appId}» не входит в список разрешённых."); + } + + return Task.CompletedTask; + } + }; + }); + + services.AddAuthorization(options => + { + options.AddPolicy(AuthPolicies.Delegated, policy => + { + policy.AuthenticationSchemes.Add(AuthSchemes.Bearer); + policy.RequireAuthenticatedUser(); + }); + + options.AddPolicy(AuthPolicies.AppOnly, policy => + { + policy.AuthenticationSchemes.Add(AuthSchemes.AppOnly); + policy.RequireAuthenticatedUser(); + }); + + // RequireAssertion, а не RequireAuthenticatedUser: политика с двумя + // схемами падает, если ОДНА из них отвергла токен, даже когда вторая + // его приняла. Здесь достаточно, чтобы аутентифицировала любая. + options.AddPolicy(AuthPolicies.Any, policy => + { + policy.AddAuthenticationSchemes(AuthSchemes.Bearer, AuthSchemes.AppOnly); + policy.RequireAssertion(ctx => ctx.User.Identities.Any(i => i.IsAuthenticated)); + }); + + options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!; + }); + } + + private static void AddDevAuth(IServiceCollection services) + { + services.AddAuthentication(AuthSchemes.Dev) + .AddScheme(AuthSchemes.Dev, _ => { }); + + // Те же имена политик, но поверх Dev-схемы: иначе контроллер с + // [Authorize(Policy = ...)] падал бы локально с «политика не найдена». + services.AddAuthorization(options => + { + foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any }) + { + options.AddPolicy(name, policy => + { + policy.AuthenticationSchemes.Add(AuthSchemes.Dev); + policy.RequireAuthenticatedUser(); + }); + } + + options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!; + }); + } } diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs new file mode 100644 index 0000000..5e5d3e4 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Auth; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.Reminders; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +/// +/// Просроченные шаги согласования и исполнения на текущий момент. +/// +/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает +/// Power Automate по расписанию (токен приложения), но и из админки его удобно +/// открыть под пользователем, чтобы посмотреть, что сервис реально отдаёт. +/// +[ApiController] +[Authorize(Policy = AuthPolicies.Any)] +[Route("api/reminders")] +[Produces("application/json")] +public sealed class RemindersController(ReminderService service) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task List(CancellationToken ct) => + (await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult(); +} diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 12d7319..178f2f3 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -9,6 +9,7 @@ using Scalar.AspNetCore; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.Reminders; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; @@ -35,6 +36,7 @@ builder.Services.AddCors(options => }); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); +builder.Services.Configure(builder.Configuration.GetSection("Reminders")); builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddScoped(); builder.Services.AddOpenApi(options => diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 12fe82b..d0390ea 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -10,7 +10,8 @@ }, "Entra": { "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", - "Audience": "9232d9e2-9807-4753-808f-07250e5474b0" + "Audience": "9232d9e2-9807-4753-808f-07250e5474b0", + "AllowedAppIds": [] }, "Dev": { "DefaultUserEmail": "dev@tnsu.kz" @@ -21,6 +22,12 @@ "https://localhost:4321" ] }, + "SharePoint": { + "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", + "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", + "CertificateSettingName": "AzurePfx", + "CertificatePassword": "" + }, "TnsuPortal": { "BaseUrl": "https://api.tnsu.kz", "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index e466d09..3e5732c 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.Reminders; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; @@ -11,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions { public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services) { + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs new file mode 100644 index 0000000..13bf646 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Строка списка «… — согласование», разобранная из ответа SharePoint. +/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts): +/// именно он эти списки и создаёт. +/// +internal sealed record ApprovalRow( + int Id, + int TicketId, + int StepOrder, + string Status, + string Phase, + DateTimeOffset? Date, + string? ApproverEmail, + int? SlaHours, + bool? AskOverdueReason) +{ + public static ApprovalRow? TryParse(JsonElement item) + { + // TicketId — Lookup на заявку; без него строку не с чем связать. + if (!TryGetInt(item, "TicketId", out var ticketId)) return null; + + var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object + ? GetString(a, "EMail") + : null; + + return new ApprovalRow( + TryGetInt(item, "Id", out var id) ? id : 0, + ticketId, + TryGetInt(item, "StepOrder", out var order) ? order : 0, + GetString(item, "ApprovalStatus") ?? string.Empty, + // Пустая фаза — старые строки, заведённые до появления колонки; + // трактуем как «Согласование» (так же поступает request-portal). + GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval, + TryGetDate(item, "Date"), + approver, + TryGetInt(item, "SLAhours", out var sla) ? sla : null, + TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null); + } + + private static string? GetString(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static bool TryGetInt(JsonElement e, string name, out int value) + { + value = 0; + return e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out value); + } + + private static bool TryGetBool(JsonElement e, string name, out bool value) + { + value = false; + if (!e.TryGetProperty(name, out var v)) return false; + if (v.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false; + value = v.GetBoolean(); + return true; + } + + private static DateTimeOffset? TryGetDate(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && + v.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(v.GetString(), out var parsed) + ? parsed + : null; +} + +internal static class ApprovalPhases +{ + public const string Approval = "Согласование"; + public const string Execution = "Исполнение"; +} + +internal static class ApprovalStatuses +{ + public const string OnApproval = "На согласовании"; + public const string OnExecution = "На исполнении"; +} + +/// +/// Managed property статуса строки маршрута для KQL — ApprovalStatusOWSCHCS. +/// Имя нестандартное (суффикс OWSCHCS у Choice-поля, префикс группы колонок +/// сайта у самого имени) — совпадает с MP_STATUS в +/// request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts, +/// это тот же список и та же колонка, имя проверено там же. +/// +internal static class SlaStatus +{ + private const string ManagedProperty = "ApprovalStatusOWSCHCS"; + + public static readonly string OnApprovalProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnApproval}\""; + public static readonly string OnExecutionProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnExecution}\""; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs new file mode 100644 index 0000000..76a4ce4 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs @@ -0,0 +1,20 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// Согласующий/исполнитель из строки списка — кому напоминать. +/// +/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). +/// Выходные не учитываются — считаются календарные часы. +/// +/// Ссылка на заявку, требующую решения. +/// +/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым +/// наследованием (шаг перекрывает тип заявки). +/// +public sealed record ReminderDto( + string Email, + int HoursOverdue, + string Url, + bool AskOverdueReason, + int RequestTypeId, + string RequestTypeName, + int RequestId); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs new file mode 100644 index 0000000..85dfa60 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -0,0 +1,295 @@ +using System.Text.Json; +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Кто прямо сейчас просрочил свой шаг согласования или исполнения. +/// +/// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный +/// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано — +/// рассылкой и защитой от повторов занимается вызывающая сторона (Power +/// Automate по расписанию). +/// +public sealed class ReminderService( + ApprovalRouteDbContext db, + ISharePointClient sharePoint, + IOptions options, + ILogger logger) +{ + // Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе + // на большом списке получится неподъёмной длины URL. + private const int IdChunkSize = 50; + + // Верхняя граница на число одновременно активных шагов по типу заявки — + // запас с большим избытком (это не общий объём заявок, а только те, что + // прямо сейчас на согласовании/исполнении). + private const int SearchRowLimit = 5000; + + public async Task>> GetOverdueAsync( + DateTimeOffset now, CancellationToken cancellationToken) + { + // Точка входа — типы заявок с настроенным SLA. Тип без SLA не участвует + // в напоминаниях вообще, даже если у его шагов SLA задан: включение + // напоминаний — решение уровня типа заявки. + var types = await db.RequestTypes.AsNoTracking() + .Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null) + .ToListAsync(cancellationToken); + + var reminders = new List(); + + foreach (var type in types) + { + try + { + reminders.AddRange(await GetOverdueForTypeAsync(type, now, cancellationToken)); + } + catch (Exception e) + { + // Недоступный список одного типа не должен обнулять напоминания по + // всем остальным: Power Automate получит то, что удалось собрать. + logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name); + } + } + + return Result.Ok>(reminders); + } + + private async Task> GetOverdueForTypeAsync( + RequestType type, DateTimeOffset now, CancellationToken cancellationToken) + { + var siteUrl = type.SiteUrl!; + var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken); + if (string.IsNullOrWhiteSpace(dataListTitle)) + { + logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId); + return []; + } + + var approvalListTitle = ApprovalListTitleFor(dataListTitle); + if (approvalListTitle is null) + { + logger.LogWarning( + "Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти", + dataListTitle); + return []; + } + + // Шаг 1. Активные строки — через Search, а не через $filter: дело не в + // кириллице конкретно, а в том, что $filter по Choice-колонке в + // SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо + // ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки + // этого же списка уже проверена и используется в request-portal + // (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут. + // + // Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ + // нерешённые строки списка (queued + active), чтобы отобрать активные + // на клиенте — при большом архиве заявок это росло бы неограниченно. + // Search сужает набор на стороне SharePoint до тех, что реально нужны. + var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken); + if (string.IsNullOrWhiteSpace(relativeUrl)) + { + logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle); + return []; + } + + var pathClause = $"Path:\"{siteUrl.TrimEnd('/')}{relativeUrl}*\""; + var statusClause = $"({SlaStatus.OnApprovalProperty} OR {SlaStatus.OnExecutionProperty})"; + var activeIds = await sharePoint.SearchListItemIdsAsync( + siteUrl, $"{pathClause} AND {statusClause}", SearchRowLimit, cancellationToken); + if (activeIds.Count == 0) return []; + + // Search отдаёт только Id — реальные поля (email, SlaHours, TicketId и + // т.д.) дочитываем REST'ом, как и везде в этом сервисе (см. ReadCreatedAsync). + // Заодно и статус здесь берётся из свежих данных, а не из индекса, который + // мог успеть отстать (та же оговорка, что в request-portal). + var active = (await ReadRowsByIdsAsync(siteUrl, approvalListTitle, activeIds, cancellationToken)) + .Where(r => r.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution) + .ToList(); + if (active.Count == 0) return []; + + // Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута — + // дата активации шага выводится из дат решений предыдущего шага. + var ticketIds = active.Select(r => r.TicketId).Distinct().ToList(); + var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken); + var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList()); + + var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken); + + var reminders = new List(); + foreach (var row in active) + { + if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue; + if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue; + + createdByTicket.TryGetValue(row.TicketId, out var created); + var activatedAt = ActivatedAt(row, ticketRows, created); + if (activatedAt is null) + { + logger.LogWarning( + "Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена", + row.Id, row.TicketId); + continue; + } + + // SLA строки перекрывает типовой. У строк, созданных до появления + // колонки, значения нет — тогда работает SLA типа заявки. + var slaHours = row.SlaHours ?? type.SlaHours!.Value; + var deadline = activatedAt.Value.AddHours(slaHours); + if (deadline >= now) continue; + + reminders.Add(new ReminderDto( + row.ApproverEmail!, + (int)Math.Floor((now - deadline).TotalHours), + BuildUrl(type, row), + row.AskOverdueReason ?? type.AskOverdueReason ?? false, + type.Id, + type.Name, + row.TicketId)); + } + + return reminders; + } + + /// + /// Момент, с которого шаг считается активным, — начало отсчёта SLA. + /// + /// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это + /// точечное обновление статуса, дату оно не пишет), поэтому момент выводится + /// из соседних строк: + /// • первый шаг согласования — дата создания заявки; + /// • первый шаг исполнения — последнее решение по фазе согласования + /// (фаза исполнения активируется только когда согласование пройдено целиком); + /// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы. + /// + /// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить + /// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг + /// активируется только после решения последнего из них. + /// + /// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не + /// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по + /// условию появления). + /// + private static DateTimeOffset? ActivatedAt( + ApprovalRow row, IReadOnlyList ticketRows, DateTimeOffset? requestCreated) + { + var samePhase = ticketRows.Where(r => r.Phase == row.Phase).ToList(); + var isFirstInPhase = samePhase.All(r => r.StepOrder >= row.StepOrder); + + if (isFirstInPhase) + { + return row.Phase == ApprovalPhases.Execution + ? LastDecisionOf(ticketRows.Where(r => r.Phase == ApprovalPhases.Approval)) + : requestCreated; + } + + var previousOrder = samePhase + .Where(r => r.StepOrder < row.StepOrder) + .Max(r => r.StepOrder); + + return LastDecisionOf(samePhase.Where(r => r.StepOrder == previousOrder)); + } + + private static DateTimeOffset? LastDecisionOf(IEnumerable rows) + { + var dates = rows.Select(r => r.Date).Where(d => d is not null).Select(d => d!.Value).ToList(); + return dates.Count == 0 ? null : dates.Max(); + } + + /// + /// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг: + /// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно + /// откроется панелью поверх раздела (rpmode=approve). + /// + private string BuildUrl(RequestType type, ApprovalRow row) => + options.Value.RequestUrlTemplate + .Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/')) + .Replace("{pageUrl}", type.PageUrl) + .Replace("{requestId}", row.TicketId.ToString()) + .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,StepOrder,Phase,Date,SLAhours,AskOverdueReason,TicketId," + + "TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000"; + + private async Task> ReadRowsAsync( + string siteUrl, string listTitle, string filter, CancellationToken cancellationToken) + { + var items = await sharePoint.GetItemsByListTitleAsync( + siteUrl, listTitle, $"{RowSelect}&{filter}", cancellationToken); + + return Parse(items); + } + + /// Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync). + private async Task> ReadRowsByIdsAsync( + string siteUrl, string listTitle, IReadOnlyList ids, CancellationToken cancellationToken) + { + var rows = new List(); + foreach (var chunk in ids.Chunk(IdChunkSize)) + { + var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}")); + rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken)); + } + + return rows; + } + + private async Task> ReadRowsByTicketsAsync( + string siteUrl, string listTitle, IReadOnlyList ticketIds, CancellationToken cancellationToken) + { + var rows = new List(); + foreach (var chunk in ticketIds.Chunk(IdChunkSize)) + { + var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"TicketId eq {id}")); + rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken)); + } + + return rows; + } + + /// Даты создания заявок — начало отсчёта для первого шага согласования. + private async Task> ReadCreatedAsync( + string siteUrl, string listUrl, IReadOnlyList ids, CancellationToken cancellationToken) + { + var created = new Dictionary(); + + foreach (var chunk in ids.Chunk(IdChunkSize)) + { + var query = "$select=Id,Created&$top=5000&$filter=" + + string.Join(" or ", chunk.Select(id => $"Id eq {id}")); + + var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listUrl, query, cancellationToken); + foreach (var item in items) + { + if (!item.TryGetProperty("Id", out var idValue) || !idValue.TryGetInt32(out var id)) continue; + if (!item.TryGetProperty("Created", out var createdValue)) continue; + if (createdValue.ValueKind != JsonValueKind.String) continue; + if (DateTimeOffset.TryParse(createdValue.GetString(), out var parsed)) created[id] = parsed; + } + } + + return created; + } + + private static List Parse(IEnumerable items) => + items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs new file mode 100644 index 0000000..54ff0ac --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs @@ -0,0 +1,21 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +public sealed class RemindersOptions +{ + /// + /// Шаблон ссылки на заявку. Подстановки: {siteUrl}, {pageUrl}, {requestId}, {nav}. + /// + /// Портал — одностраничный и живёт в КОРНЕ сайта, отдельного пути к странице + /// нет; всё состояние передаётся параметрами запроса (см. shared/routing.ts + /// в request-portal). Пример живой ссылки: + /// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11 + /// + /// {pageUrl} — это PageUrl типа заявки, то есть КЛЮЧ ФОРМЫ (it-1c-ticket), а + /// не путь к странице: в портале он и подставляется в rptype. + /// + /// Вынесено в конфигурацию, чтобы смена разметки ссылок в портале правилась + /// настройкой, а не пересборкой сервиса. + /// + public string RequestUrlTemplate { get; set; } = + "{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}"; +} diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index bc44c5a..b8992e4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -1,9 +1,42 @@ namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListId, string? SiteUrl, string Section); +// SlaHours/AskOverdueReason добавлены в конец с значением по умолчанию null — +// оба поля необязательны, и старые клиенты, не знающие о них, продолжают +// работать без изменений. null здесь значит «не настроено», а не «ноль»: +// см. RequestType.SlaHours. +public sealed record RequestTypeDto( + int Id, + string Name, + string PageUrl, + string? ListId, + string? SiteUrl, + string Section, + int? SlaHours = null, + bool? AskOverdueReason = null); -public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section); +public sealed record CreateRequestTypeRequest( + string Name, + string PageUrl, + string? ListId, + string? SiteUrl, + string Section, + int? SlaHours = null, + bool? AskOverdueReason = null); -public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section); +public sealed record UpdateRequestTypeRequest( + string Name, + string PageUrl, + string? ListId, + string? SiteUrl, + string Section, + int? SlaHours = null, + bool? AskOverdueReason = null); -public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListId, string? SiteUrl, string? Section); +public sealed record PatchRequestTypeRequest( + string? Name, + string? PageUrl, + string? ListId, + string? SiteUrl, + string? Section, + int? SlaHours = null, + bool? AskOverdueReason = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 296f600..fa58fa8 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) { var items = await db.RequestTypes.AsNoTracking() .OrderBy(x => x.Name) - .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section)) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section, x.SlaHours, x.AskOverdueReason)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -39,7 +39,9 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) PageUrl = request.PageUrl, ListId = request.ListId, SiteUrl = request.SiteUrl, - Section = request.Section + Section = request.Section, + SlaHours = request.SlaHours, + AskOverdueReason = request.AskOverdueReason }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); @@ -62,6 +64,8 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.ListId = request.ListId; entity.SiteUrl = request.SiteUrl; entity.Section = request.Section; + entity.SlaHours = request.SlaHours; + entity.AskOverdueReason = request.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); @@ -85,6 +89,11 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) entity.ListId = listId; entity.SiteUrl = siteUrl; entity.Section = request.Section ?? entity.Section; + // PATCH здесь традиционно трактует null как «не менять» (как и остальные + // поля выше), поэтому сбросить SLA обратно в «наследовать» можно только + // через PUT — там значение записывается как есть. + entity.SlaHours = request.SlaHours ?? entity.SlaHours; + entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); @@ -102,5 +111,6 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(); } - private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section); + private static RequestTypeDto ToDto(RequestType e) => + new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section, e.SlaHours, e.AskOverdueReason); } diff --git a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index 8a47466..c830a20 100644 --- a/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -12,6 +12,12 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator x) .Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl)) .WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); + // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; + // ноль допустим — это осознанное «напоминать сразу». + RuleFor(x => x.SlaHours) + .GreaterThanOrEqualTo(0) + .When(x => x.SlaHours is not null) + .WithMessage("SLA в часах не может быть отрицательным."); } } @@ -25,6 +31,12 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x) .Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl)) .WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); + // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; + // ноль допустим — это осознанное «напоминать сразу». + RuleFor(x => x.SlaHours) + .GreaterThanOrEqualTo(0) + .When(x => x.SlaHours is not null) + .WithMessage("SLA в часах не может быть отрицательным."); } } @@ -38,6 +50,12 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); When(x => x.Section is not null, () => RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым.")); + // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; + // ноль допустим — это осознанное «напоминать сразу». + RuleFor(x => x.SlaHours) + .GreaterThanOrEqualTo(0) + .When(x => x.SlaHours is not null) + .WithMessage("SLA в часах не может быть отрицательным."); } } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 200c375..5736e17 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -2,6 +2,11 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; +// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки. +// null — «не задано, наследовать», поэтому оба поля nullable и необязательны +// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец +// записей, чтобы не ломать существующие позиционные вызовы. + public sealed record TemplateRouteStepDto( int Id, int OrderNo, @@ -15,7 +20,9 @@ public sealed record TemplateRouteStepDto( string? FieldName, ComparisonOperator? Operation, string? Value, - string? Warning = null); + string? Warning = null, + int? SlaHours = null, + bool? AskOverdueReason = null); public sealed record CreateTemplateRouteStepRequest( int OrderNo, @@ -27,7 +34,9 @@ public sealed record CreateTemplateRouteStepRequest( bool AllowReassignToOtherApprovers, string? FieldName, ComparisonOperator? Operation, - string? Value); + string? Value, + int? SlaHours = null, + bool? AskOverdueReason = null); public sealed record UpdateTemplateRouteStepRequest( int OrderNo, @@ -39,7 +48,9 @@ public sealed record UpdateTemplateRouteStepRequest( bool AllowReassignToOtherApprovers, string? FieldName, ComparisonOperator? Operation, - string? Value); + string? Value, + int? SlaHours = null, + bool? AskOverdueReason = null); public sealed record PatchTemplateRouteStepRequest( int? OrderNo, @@ -51,4 +62,6 @@ public sealed record PatchTemplateRouteStepRequest( bool? AllowReassignToOtherApprovers, string? FieldName, ComparisonOperator? Operation, - string? Value); + string? Value, + int? SlaHours = null, + bool? AskOverdueReason = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 8d6c11a..516efa9 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -16,7 +16,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) .OrderBy(x => x.OrderNo) .Select(x => new TemplateRouteStepDto( x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, - x.FieldName, x.Operation, x.Value)) + x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -73,7 +73,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, FieldName = r.FieldName, Operation = r.Operation, - Value = r.Value + Value = r.Value, + SlaHours = r.SlaHours, + AskOverdueReason = r.AskOverdueReason }; db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); @@ -125,6 +127,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.FieldName = r.FieldName; entity.Operation = r.Operation; entity.Value = r.Value; + entity.SlaHours = r.SlaHours; + entity.AskOverdueReason = r.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); @@ -190,6 +194,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.FieldName = fieldName; entity.Operation = operation; entity.Value = value; + // Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к + // наследованию SLA от типа заявки можно через PUT. + entity.SlaHours = r.SlaHours ?? entity.SlaHours; + entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); @@ -281,5 +289,5 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) 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.FieldName, e.Operation, e.Value); + e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 1c4ac4b..577edf1 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -14,6 +14,12 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< RuleFor(x => x) .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); + // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; + // ноль допустим — это осознанное «напоминать сразу». + RuleFor(x => x.SlaHours) + .GreaterThanOrEqualTo(0) + .When(x => x.SlaHours is not null) + .WithMessage("SLA в часах не может быть отрицательным."); } } @@ -28,6 +34,12 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< RuleFor(x => x) .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); + // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; + // ноль допустим — это осознанное «напоминать сразу». + RuleFor(x => x.SlaHours) + .GreaterThanOrEqualTo(0) + .When(x => x.SlaHours is not null) + .WithMessage("SLA в часах не может быть отрицательным."); } } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index ceb8cd6..1aba714 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -2,13 +2,26 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; +/// +/// ДЕЙСТВУЮЩИЙ SLA шага в часах — наследование уже применено: значение шага, а +/// если на шаге не задано, то значение типа заявки. null означает, что SLA не +/// настроен нигде, то есть напоминаний по этому шагу не будет. +/// Резолв отдаёт именно итоговое значение, чтобы вызывающая сторона +/// (request-portal) просто записала его в строку списка согласования и не +/// повторяла у себя правило наследования. +/// +/// +/// Спрашивать ли причину просрочки — так же с уже применённым наследованием. +/// public sealed record ResolvedRouteStepDto( int Id, int OrderNo, string? ExecutorEmail, string? Position, bool AllowReassignToOtherApprovers, - string? Warning = null); + string? Warning = null, + int? SlaHours = null, + bool? AskOverdueReason = null); 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 8bdbaec..3d210d4 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -129,8 +129,11 @@ public sealed class TemplateRouteService( return Result.Fail( new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken); - if (!requestTypeExists) + // Сам тип заявки, а не только факт его существования: из него берутся + // значения SLA по умолчанию для шагов (см. ResolveStepAsync). + var requestType = await db.RequestTypes.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.RequestTypeId, cancellationToken); + if (requestType is null) return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); @@ -168,7 +171,8 @@ public sealed class TemplateRouteService( var steps = new List(); foreach (var step in matchingSteps) - steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, request.DepartmentId, cancellationToken)); + steps.Add(await ResolveStepAsync( + step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken)); var collapsedSteps = CollapseByExecutorEmail(steps); @@ -212,7 +216,8 @@ public sealed class TemplateRouteService( private async Task ResolveStepAsync( TemplateRouteStep step, string initiatorEmail, string initiatorCompany, - IReadOnlyList projectExecutors, Guid? departmentId, CancellationToken cancellationToken) + IReadOnlyList projectExecutors, Guid? departmentId, RequestType requestType, + CancellationToken cancellationToken) { string? resolvedEmail; string? warning = null; @@ -277,7 +282,19 @@ public sealed class TemplateRouteService( resolvedPosition = resolvedEmployee?.Position; } - return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning); + // Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??` + // работает именно так, как задумано — значение шага перекрывает типовое, + // и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым + // (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours). + return new ResolvedRouteStepDto( + step.Id, + step.OrderNo, + resolvedEmail, + resolvedPosition, + step.AllowReassignToOtherApprovers, + warning, + step.SlaHours ?? requestType.SlaHours, + step.AskOverdueReason ?? requestType.AskOverdueReason); } /// diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index b47fa57..70db213 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -9,5 +9,21 @@ public class RequestType public string? SiteUrl { get; set; } public string Section { get; set; } = string.Empty; + /// + /// SLA по умолчанию для ВСЕХ шагов заявок этого типа, в часах. Отсчёт идёт от + /// активации шага, а не от создания заявки (см. эндпоинт напоминаний). + /// null — SLA не настроен, напоминания по этому типу не строятся. + /// Значение на шаге () перекрывает это. + /// + public int? SlaHours { get; set; } + + /// + /// Спрашивать ли у согласующего причину просрочки — значение по умолчанию для + /// всех шагов заявок этого типа. null — не задано. + /// Отдаётся в ответе эндпоинта напоминаний, чтобы вызывающая сторона решила, + /// запрашивать причину или просто напомнить. + /// + public bool? AskOverdueReason { get; set; } + public ICollection Routes { get; set; } = new List(); } diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index ce5a6be..665bd01 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -16,6 +16,21 @@ public class TemplateRouteStep public int RouteId { get; set; } public bool AllowReassignToOtherApprovers { get; set; } + /// + /// SLA этого шага в часах — перекрывает . + /// null означает ИМЕННО «не задано, наследовать от типа заявки», а 0 — + /// осознанное «напоминать сразу», поэтому поле обязано быть nullable и не + /// должно получать значение по умолчанию. + /// + public int? SlaHours { get; set; } + + /// + /// Спрашивать ли причину просрочки на этом шаге — перекрывает + /// . Здесь тоже три состояния: + /// null — наследовать от типа, true/false — явный выбор. + /// + public bool? AskOverdueReason { get; set; } + public string? FieldName { get; set; } public ComparisonOperator? Operation { get; set; } public string? Value { get; set; } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index fccf5ce..ca7ef6f 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure; @@ -20,6 +21,12 @@ public static class InfrastructureServiceCollectionExtensions .UseSnakeCaseNamingConvention()); services.AddAsyncInitializer(); + services.Configure(configuration.GetSection("SharePoint")); + // Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё + // время жизни процесса (см. DbCertificateProvider). + services.AddSingleton(); + services.AddHttpClient(); + services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); services.AddHttpClient((sp, client) => diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 5aeb2f4..3bf9ee4 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; @@ -8,9 +9,20 @@ public class ApprovalRouteDbContext(DbContextOptions opt public DbSet RequestTypes => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); + public DbSet Settings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(e => + { + // Имя таблицы задано явно: соглашение по именам сделало бы из + // AppSetting «app_settings», а таблица должна называться так же, как + // в api-tnsu-portal, — settings. + e.ToTable("settings"); + e.HasKey(x => x.OptionName); + e.Property(x => x.OptionName).HasMaxLength(128); + }); + modelBuilder.Entity(e => { e.HasKey(x => x.Id); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Entities/AppSetting.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Entities/AppSetting.cs new file mode 100644 index 0000000..6b7f8bf --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Entities/AppSetting.cs @@ -0,0 +1,24 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities; + +/// +/// Настройка, которую нельзя держать в appsettings — сейчас это единственный +/// случай: PFX-сертификат для похода в SharePoint (option_name = 'AzurePfx'). +/// +/// Живёт в Infrastructure, а не в Domain, намеренно: к предметной области +/// маршрутов согласования отношения не имеет, это способ хранения секрета. +/// +/// Форма таблицы повторяет settings в api-tnsu-portal (option_name + file_data), +/// чтобы копию сертификата можно было перенести запросом один в один, без +/// преобразований. +/// +public class AppSetting +{ + /// Ключ настройки. Для сертификата SharePoint — 'AzurePfx'. + public string OptionName { get; set; } = string.Empty; + + /// Двоичное содержимое (для 'AzurePfx' — сам .pfx как есть). + public byte[]? FileData { get; set; } + + /// Текстовое значение — для настроек, которым не нужен файл. + public string? Value { get; set; } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.Designer.cs new file mode 100644 index 0000000..3630191 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.Designer.cs @@ -0,0 +1,254 @@ +// +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("20260812091325_AddSlaSettings")] + partial class AddSlaSettings + { + /// + 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.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("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("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + 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.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + 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.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.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.RequestType", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.cs new file mode 100644 index 0000000..90c61dc --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260812091325_AddSlaSettings.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSlaSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ask_overdue_reason", + table: "template_route_steps", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "sla_hours", + table: "template_route_steps", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ask_overdue_reason", + table: "request_types", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "sla_hours", + table: "request_types", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "settings", + columns: table => new + { + option_name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + file_data = table.Column(type: "bytea", nullable: true), + value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_settings", x => x.option_name); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "settings"); + + migrationBuilder.DropColumn( + name: "ask_overdue_reason", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "sla_hours", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "ask_overdue_reason", + table: "request_types"); + + migrationBuilder.DropColumn( + name: "sla_hours", + table: "request_types"); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 60b1783..a3209ad 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -31,6 +31,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + b.Property("ListId") .HasMaxLength(512) .HasColumnType("character varying(512)") @@ -59,6 +63,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("site_url"); + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + b.HasKey("Id") .HasName("pk_request_types"); @@ -117,6 +125,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("allow_reassign_to_other_approvers"); + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + b.Property("ExecutorEmail") .HasMaxLength(256) .HasColumnType("character varying(256)") @@ -155,6 +167,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("integer") .HasColumnName("route_id"); + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + b.Property("Status") .IsRequired() .HasMaxLength(32) @@ -175,6 +191,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("template_route_steps", (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.TemplateRoute", b => { b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/CertificateProvider.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/CertificateProvider.cs new file mode 100644 index 0000000..806ae4c --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/CertificateProvider.cs @@ -0,0 +1,70 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +public interface ICertificateProvider +{ + Task GetCertificateAsync(CancellationToken cancellationToken); +} + +/// +/// Сертификат для входа в SharePoint — из таблицы settings (option_name задаётся +/// в SharePointOptions.CertificateSettingName, по умолчанию 'AzurePfx'). +/// +/// Синглтон с кешем: разбор .pfx стоит заметно дороже запроса к базе, а сам +/// сертификат меняется раз в год. Блокировка — чтобы параллельные запросы не +/// разбирали его одновременно при холодном старте. +/// +/// Собственная таблица, а не общая с api-tnsu-portal, — осознанный выбор: базы +/// у сервисов разные и связывать их не хотим. Цена — копию сертификата придётся +/// обновлять в двух местах. +/// +public sealed class DbCertificateProvider( + IServiceScopeFactory scopeFactory, + IOptions options) : ICertificateProvider +{ + private readonly SemaphoreSlim _lock = new(1, 1); + private X509Certificate2? _cached; + + public async Task GetCertificateAsync(CancellationToken cancellationToken) + { + if (_cached is not null) return _cached; + + await _lock.WaitAsync(cancellationToken); + try + { + if (_cached is not null) return _cached; + + var settingName = options.Value.CertificateSettingName; + + // Своя область: провайдер — синглтон, а DbContext зарегистрирован + // как scoped, напрямую внедрять его сюда нельзя. + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var pfx = await db.Settings.AsNoTracking() + .Where(x => x.OptionName == settingName) + .Select(x => x.FileData) + .FirstOrDefaultAsync(cancellationToken); + + if (pfx is null || pfx.Length == 0) + throw new InvalidOperationException( + $"Сертификат не найден: в таблице settings нет записи «{settingName}» с непустым file_data."); + + _cached = X509CertificateLoader.LoadPkcs12( + pfx, + options.Value.CertificatePassword, + X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet); + + return _cached; + } + finally + { + _lock.Release(); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs new file mode 100644 index 0000000..8ca383f --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -0,0 +1,237 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Text.Json; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Options; + +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +public interface ISharePointClient +{ + /// Заголовок списка по его серверному относительному URL (см. RequestType.ListId). + Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); + + /// + /// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы + /// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync). + /// + Task GetListServerRelativeUrlAsync(string siteUrl, string listTitle, CancellationToken cancellationToken); + + /// Элементы списка данных, найденного по серверному относительному URL. + Task> GetItemsByListUrlAsync( + string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); + + /// Элементы списка, найденного по заголовку (списки согласования ищутся именно так). + Task> GetItemsByListTitleAsync( + string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); + + /// + /// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter, + /// сужается набор строк списка согласования по статусу (Choice-колонка) — + /// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в + /// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS, + /// тот же список, тот же managed property, подтверждено рабочим). Значения + /// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом. + /// + Task> SearchListItemIdsAsync( + string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken); +} + +/// +/// Доступ к SharePoint REST от имени приложения (сертификат из таблицы settings). +/// +/// Сырой HttpClient, а не PnP.Core/CSOM — по образцу api-tnsu-portal и потому, +/// что нужно всего одно действие: прочитать элементы двух списков. Тянуть ради +/// этого клиентскую библиотеку со своей объектной моделью незачем. +/// +/// Клиент намеренно «глупый»: отдаёт сырой JSON и ничего не знает ни про SLA, ни +/// про строки маршрута — вся логика напоминаний живёт в Application. +/// +public sealed class SharePointClient( + HttpClient httpClient, + ICertificateProvider certificateProvider, + IOptions options) : ISharePointClient +{ + // Токен запрашивается на КОНКРЕТНЫЙ хост SharePoint, поэтому и кеш по хосту: + // списки разных типов заявок могут жить на разных сайтах. + private readonly ConcurrentDictionary _tokens = new(StringComparer.OrdinalIgnoreCase); + private ClientCertificateCredential? _credential; + private readonly SemaphoreSlim _credentialLock = new(1, 1); + + public async Task GetListTitleAsync( + string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) + { + var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=Title"; + var json = await GetAsync(siteUrl, url, cancellationToken); + return json.TryGetProperty("Title", out var title) ? title.GetString() : null; + } + + public async Task GetListServerRelativeUrlAsync( + string siteUrl, string listTitle, CancellationToken cancellationToken) + { + var url = $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')" + + "?$select=RootFolder/ServerRelativeUrl&$expand=RootFolder"; + var json = await GetAsync(siteUrl, url, cancellationToken); + + return ParseServerRelativeUrl(json); + } + + public async Task> SearchListItemIdsAsync( + string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken) + { + var url = $"{siteUrl.TrimEnd('/')}/_api/search/query" + + $"?querytext='{Uri.EscapeDataString(queryText)}'" + + "&selectproperties='ListItemID'" + + $"&rowlimit={rowLimit}" + + "&trimduplicates=false"; + var json = await GetAsync(siteUrl, url, cancellationToken); + + return ParseSearchListItemIds(json); + } + + // Разбор вынесен в чистые статические функции — они не ходят в сеть и не + // требуют токена, поэтому их (в отличие от остального клиента) можно + // проверить тестом без живого SharePoint. Это самая непроверенная часть + // клиента: форма ответа Search REST зафиксирована документацией, но без + // реального тенанта убедиться в точном соответствии нельзя. + internal static string? ParseServerRelativeUrl(JsonElement json) => + json.TryGetProperty("RootFolder", out var rootFolder) && + rootFolder.TryGetProperty("ServerRelativeUrl", out var relUrl) + ? relUrl.GetString() + : null; + + internal static IReadOnlyList ParseSearchListItemIds(JsonElement json) + { + // Путь до строк результата — фиксированная форма ответа Search REST, + // не зависит от запроса. С odata=nometadata Cells приходит уже "голым" + // массивом (без обёртки .results) — но на случай, если сервер всё равно + // завернёт его (разные версии API отвечали по-разному на практике в этом + // проекте, см. комментарии в request-portal про managed properties), + // читаем и то, и то (см. Unwrap). + if (!json.TryGetProperty("PrimaryQueryResult", out var primary)) return []; + if (!primary.TryGetProperty("RelevantResults", out var relevant)) return []; + if (!relevant.TryGetProperty("Table", out var table)) return []; + if (!table.TryGetProperty("Rows", out var rowsElement)) return []; + + var ids = new List(); + + foreach (var row in Unwrap(rowsElement)) + { + if (!row.TryGetProperty("Cells", out var cellsElement)) continue; + + foreach (var cell in Unwrap(cellsElement)) + { + if (!cell.TryGetProperty("Key", out var key) || key.GetString() != "ListItemID") continue; + if (!cell.TryGetProperty("Value", out var value)) continue; + if (int.TryParse(value.GetString(), out var id)) ids.Add(id); + } + } + + return ids; + } + + // odata=nometadata отдаёт коллекции внутри Search-ответа "голым" массивом; + // без этого заголовка (или в старых версиях API) — обёрткой { results: [...] }. + private static IEnumerable Unwrap(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Array) return element.EnumerateArray(); + + return element.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array + ? results.EnumerateArray() + : []; + } + + public Task> GetItemsByListUrlAsync( + string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) => + GetItemsAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items?{odataQuery}", + cancellationToken); + + public Task> GetItemsByListTitleAsync( + string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken) => + GetItemsAsync( + siteUrl, + $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", + cancellationToken); + + private async Task> GetItemsAsync( + string siteUrl, string url, CancellationToken cancellationToken) + { + var json = await GetAsync(siteUrl, url, cancellationToken); + + // odata=nometadata отдаёт элементы в "value"; на всякий случай терпим и + // «голый» массив, если заголовок Accept вдруг не подействует. + if (json.ValueKind == JsonValueKind.Array) + return json.EnumerateArray().ToList(); + + return json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array + ? value.EnumerateArray().ToList() + : []; + } + + private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", await GetTokenAsync(siteUrl, cancellationToken)); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") + { + Parameters = { new NameValueHeaderValue("odata", "nometadata") } + }); + + using var response = await httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException( + $"SharePoint ответил {(int)response.StatusCode} на {url}: {body}"); + + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } + + private async Task GetTokenAsync(string siteUrl, CancellationToken cancellationToken) + { + var host = new Uri(siteUrl).Host; + + // Токен живой ещё пять минут — переиспользуем. Запас нужен, чтобы токен + // не истёк между проверкой и приходом запроса в SharePoint. + if (_tokens.TryGetValue(host, out var cached) && cached.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5)) + return cached.Token; + + var credential = await GetCredentialAsync(cancellationToken); + var token = await credential.GetTokenAsync( + new TokenRequestContext([$"https://{host}/.default"]), cancellationToken); + + _tokens[host] = token; + return token.Token; + } + + private async Task GetCredentialAsync(CancellationToken cancellationToken) + { + if (_credential is not null) return _credential; + + await _credentialLock.WaitAsync(cancellationToken); + try + { + if (_credential is not null) return _credential; + + var certificate = await certificateProvider.GetCertificateAsync(cancellationToken); + _credential = new ClientCertificateCredential( + options.Value.TenantId, options.Value.ClientId, certificate); + + return _credential; + } + finally + { + _credentialLock.Release(); + } + } + + // Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки + // списков содержат тире и кириллицу, но кавычка теоретически возможна — + // без экранирования она сломала бы запрос. + private static string Escape(string value) => value.Replace("'", "''"); +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs new file mode 100644 index 0000000..71186da --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointOptions.cs @@ -0,0 +1,23 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +public sealed class SharePointOptions +{ + /// Тенант Entra — тот же, что у остальной авторизации. + public string TenantId { get; set; } = string.Empty; + + /// + /// Приложение, от имени которого идём в SharePoint. Ему нужны разрешения + /// уровня приложения на чтение списков, выданные администратором тенанта. + /// + public string ClientId { get; set; } = string.Empty; + + /// Ключ в таблице settings, под которым лежит .pfx (option_name). + public string CertificateSettingName { get; set; } = "AzurePfx"; + + /// + /// Пароль от .pfx. Намеренно берётся из конфигурации, а не зашивается в код: + /// в api-tnsu-portal он захардкожен в PostgresCertificateProvider, и повторять + /// это здесь не стоит — пароль от сертификата в исходниках не место. + /// + public string CertificatePassword { get; set; } = string.Empty; +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index 33b62bc..c55b907 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -3,6 +3,15 @@ + + + + + + diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs new file mode 100644 index 0000000..1474824 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -0,0 +1,285 @@ +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.Reminders; +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, — так тесты заодно покрывают и разбор ответа. +/// +public sealed class ReminderServiceTests : 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, 8, 12, 12, 0, 0, TimeSpan.Zero); + + private readonly ServiceProvider _services; + private readonly RequestTypeService _requestTypeService; + + public ReminderServiceTests(PostgresDatabaseFixture fixture) + { + _services = TestServiceProviderFactory.Build(fixture.ConnectionString); + _requestTypeService = _services.GetRequiredService(); + } + + // База у тестов класса общая, а сервис обходит ВСЕ типы с настроенным SLA — + // поэтому у каждого теста свой список: типы соседних тестов на мок не + // настроены, заголовок списка для них не найдётся, и в выборку они не попадут. + private async Task CreateTypeWithSlaAsync(string name, int slaHours, bool? askOverdueReason = null) + { + var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}"; + await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours, askOverdueReason), + CancellationToken.None); + + return listUrl; + } + + private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); + + private static object Row( + int id, int ticketId, int stepOrder, string status, string phase, + string? date = null, string email = "approver@tnsu.kz", int? slaHours = null, bool? ask = null) => + new + { + Id = id, + TicketId = ticketId, + StepOrder = stepOrder, + ApprovalStatus = status, + Phase = phase, + Date = date, + SLAhours = slaHours, + AskOverdueReason = ask, + TansuSiteApprover = new { EMail = email } + }; + + private ReminderService BuildService( + string listUrl, IReadOnlyList allRows, DateTimeOffset requestCreated) + { + var sharePoint = new Mock(); + + sharePoint + .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) + .ReturnsAsync(DataListTitle); + + sharePoint + .Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny())) + .ReturnsAsync("/sites/requests/Lists/ApprovalListRelUrl"); + + // Search сужает набор до активных строк (На согласовании/На исполнении) — + // мок отвечает так же, как реальный Search: по статусу строки, не по дате. + var activeRows = allRows + .Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is + "На согласовании" or "На исполнении") + .ToList(); + sharePoint + .Setup(x => x.SearchListItemIdsAsync( + SiteUrl, It.Is(q => q.Contains("ApprovalStatusOWSCHCS")), It.IsAny(), It.IsAny())) + .ReturnsAsync(activeRows.Select(r => (int)r.GetType().GetProperty("Id")!.GetValue(r)!).ToList()); + + // Дочитывание полных полей найденных Search'ем строк — по Id. Матчер ищет + // "filter=Id eq", а не просто "Id eq" — иначе он совпал бы и с + // "TicketId eq" (та подстрока в нём есть буквально), и два setup'а + // конфликтовали бы между собой. + sharePoint + .Setup(x => x.GetItemsByListTitleAsync( + SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("filter=Id eq")), It.IsAny())) + .ReturnsAsync(activeRows.Select(Json).ToList()); + + sharePoint + .Setup(x => x.GetItemsByListTitleAsync( + SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("TicketId eq")), It.IsAny())) + .ReturnsAsync(allRows.Select(Json).ToList()); + + sharePoint + .Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny(), It.IsAny())) + .ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]); + + return new ReminderService( + _services.GetRequiredService(), + sharePoint.Object, + Options.Create(new RemindersOptions()), + NullLogger.Instance); + } + + [Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")] + public async Task FirstApprovalStep_CountsFromRequestCreated() + { + var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24); + // Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа. + var service = BuildService(listUrl, + [Row(10, 1, 1, "На согласовании", "Согласование")], + requestCreated: Now.AddHours(-48)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + var reminder = result.Value.ShouldHaveSingleItem(); + reminder.Email.ShouldBe("approver@tnsu.kz"); + reminder.HoursOverdue.ShouldBe(24); + reminder.RequestId.ShouldBe(1); + } + + [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] + public async Task WithinSla_ReturnsNothing() + { + var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24); + var service = BuildService(listUrl, + [Row(10, 1, 1, "На согласовании", "Согласование")], + requestCreated: Now.AddHours(-2)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldBeEmpty(); + } + + [Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")] + public async Task SecondStep_CountsFromPreviousStepDecision() + { + var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10); + // Заявка создана давно, но предыдущий шаг решён 12 часов назад: + // при отсчёте от создания просрочка была бы огромной, верная — 2 часа. + var service = BuildService(listUrl, + [ + Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")), + Row(11, 1, 2, "На согласовании", "Согласование", email: "second@tnsu.kz") + ], + requestCreated: Now.AddDays(-30)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + var reminder = result.Value.ShouldHaveSingleItem(); + reminder.Email.ShouldBe("second@tnsu.kz"); + reminder.HoursOverdue.ShouldBe(2); + } + + [Fact(DisplayName = "Параллельный предыдущий шаг: отсчёт от ПОСЛЕДНЕГО решения, а не от первого")] + public async Task ParallelPreviousStep_CountsFromLastDecision() + { + var listUrl = await CreateTypeWithSlaAsync("Sla parallel", slaHours: 10); + // Двое делят StepOrder = 1. Следующий шаг активируется только после + // решения второго (-12 ч), а не первого (-40 ч). + var service = BuildService(listUrl, + [ + Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")), + Row(11, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")), + Row(12, 1, 2, "На согласовании", "Согласование", email: "next@tnsu.kz") + ], + requestCreated: Now.AddDays(-30)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2); + } + + [Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")] + public async Task FirstExecutionStep_CountsFromLastApprovalDecision() + { + var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5); + var service = BuildService(listUrl, + [ + Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-30).ToString("O")), + Row(11, 1, 2, "Согласовано", "Согласование", date: Now.AddHours(-8).ToString("O")), + Row(12, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz") + ], + requestCreated: Now.AddDays(-30)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + var reminder = result.Value.ShouldHaveSingleItem(); + reminder.Email.ShouldBe("executor@tnsu.kz"); + reminder.HoursOverdue.ShouldBe(3); + } + + [Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")] + public async Task RowSla_OverridesRequestTypeSla() + { + var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100); + // По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов. + var service = BuildService(listUrl, + [Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1)], + requestCreated: Now.AddHours(-6)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5); + } + + [Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")] + public async Task AskOverdueReason_InheritsFromRequestType() + { + var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true); + var service = BuildService(listUrl, + [Row(10, 1, 1, "На согласовании", "Согласование")], + requestCreated: Now.AddHours(-6)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().AskOverdueReason.ShouldBeTrue(); + } + + // Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён + // живым примером: + // https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11 + [Fact(DisplayName = "Ссылка на заявку собирается в том же виде, что и в портале")] + public async Task Url_MatchesPortalFormat() + { + var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1); + var service = BuildService(listUrl, + [Row(10, 11, 1, "На согласовании", "Согласование")], + requestCreated: Now.AddHours(-6)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().Url.ShouldBe( + "https://tnsukz.sharepoint.com/sites/requests" + + "?rpnav=approvals&rpmode=approve&rptype=/it-1c-ticket&rpid=11"); + } + + [Fact(DisplayName = "Просроченный шаг исполнения ведёт в раздел «На исполнении», а не в согласование")] + public async Task Url_ForExecutionPhase_PointsToExecutionSection() + { + var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1); + var service = BuildService(listUrl, + [ + Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-6).ToString("O")), + Row(11, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz") + ], + requestCreated: Now.AddDays(-30)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().Url.ShouldContain("rpnav=execution"); + } + + [Fact(DisplayName = "Ждущие очереди строки в напоминания не попадают")] + public async Task QueuedRows_AreIgnored() + { + var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1); + var service = BuildService(listUrl, + [ + Row(10, 1, 1, "На согласовании", "Согласование"), + Row(11, 1, 2, "Ждёт очереди", "Согласование", email: "waiting@tnsu.kz") + ], + requestCreated: Now.AddHours(-6)); + + var result = await service.GetOverdueAsync(Now, CancellationToken.None); + + result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz"); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 8bbd080..cc77636 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -181,6 +181,80 @@ public sealed class TemplateRouteServiceTests : IClassFixture CreateRequestTypeWithSlaAsync(string name, int? slaHours, bool? askOverdueReason) => + (await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(name, $"/{name}", null, null, "General", slaHours, askOverdueReason), + CancellationToken.None)).Value.Id; + + private async Task ResolveSingleStepWithSlaAsync( + string requestTypeName, int? typeSla, bool? typeAsk, int? stepSla, bool? stepAsk) + { + var requestTypeId = await CreateRequestTypeWithSlaAsync(requestTypeName, typeSla, typeAsk); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest( + 1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null, + stepSla, stepAsk), + CancellationToken.None); + + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + resolved.IsSuccess.ShouldBeTrue(); + return resolved.Value.Steps.ShouldHaveSingleItem(); + } + + [Fact(DisplayName = "Резолв: шаг без своего SLA наследует часы и флаг от типа заявки")] + public async Task ResolveAsync_StepWithoutSla_InheritsFromRequestType() + { + var step = await ResolveSingleStepWithSlaAsync( + "Sla inherit", typeSla: 48, typeAsk: true, stepSla: null, stepAsk: null); + + step.SlaHours.ShouldBe(48); + step.AskOverdueReason.ShouldBe(true); + } + + [Fact(DisplayName = "Резолв: SLA шага перекрывает SLA типа заявки")] + public async Task ResolveAsync_StepSla_OverridesRequestTypeSla() + { + var step = await ResolveSingleStepWithSlaAsync( + "Sla override", typeSla: 48, typeAsk: true, stepSla: 8, stepAsk: false); + + step.SlaHours.ShouldBe(8); + step.AskOverdueReason.ShouldBe(false); + } + + // Ключевое отличие «не задано» от «ноль»: ноль на шаге — это осознанное + // «напоминать сразу», и подменять его типовым значением нельзя. + [Fact(DisplayName = "Резолв: заданный на шаге SLA = 0 не подменяется значением типа заявки")] + public async Task ResolveAsync_StepSlaZero_IsNotReplacedByRequestTypeSla() + { + var step = await ResolveSingleStepWithSlaAsync( + "Sla zero", typeSla: 48, typeAsk: true, stepSla: 0, stepAsk: false); + + step.SlaHours.ShouldBe(0); + step.AskOverdueReason.ShouldBe(false); + } + + [Fact(DisplayName = "Резолв: SLA не настроен ни на шаге, ни на типе — в ответе null")] + public async Task ResolveAsync_NoSlaAnywhere_ReturnsNull() + { + var step = await ResolveSingleStepWithSlaAsync( + "Sla absent", typeSla: null, typeAsk: null, stepSla: null, stepAsk: null); + + step.SlaHours.ShouldBeNull(); + step.AskOverdueReason.ShouldBeNull(); + } + [Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")] public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo() { diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs new file mode 100644 index 0000000..945435d --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/SharePointClientParsingTests.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using Shouldly; +using Tnsu.ApprovalRoute.Infrastructure.SharePoint; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +/// +/// Разбор ответов SharePoint REST — единственная часть SharePointClient, которую +/// можно проверить без живого тенанта: сам HTTP-вызов неотделим от аутентификации +/// по сертификату (ClientCertificateCredential ходит в Azure AD за токеном перед +/// каждым запросом), а вот разбор уже полученного JSON — чистая функция. +/// +/// Это самое непроверенное место всей интеграции: форма ответа Search REST +/// (_api/search/query) задокументирована, но подтвердить её точное соответствие +/// без реального тенанта нельзя — только этими тестами на образцах ответа. +/// +public sealed class SharePointClientParsingTests +{ + private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement; + + [Fact(DisplayName = "Search: с odata=nometadata Rows/Cells приходят голыми массивами")] + public void ParseSearchListItemIds_BareArrays_ReturnsIds() + { + const string response = """ + { + "PrimaryQueryResult": { + "RelevantResults": { + "RowCount": 2, + "Table": { + "Rows": [ + { "Cells": [ { "Key": "Rank", "Value": "1" }, { "Key": "ListItemID", "Value": "11" } ] }, + { "Cells": [ { "Key": "ListItemID", "Value": "27" }, { "Key": "Rank", "Value": "2" } ] } + ] + } + } + } + } + """; + + var ids = SharePointClient.ParseSearchListItemIds(Parse(response)); + + ids.ShouldBe([11, 27]); + } + + [Fact(DisplayName = "Search: без odata=nometadata Rows/Cells завёрнуты в {results: [...]}")] + public void ParseSearchListItemIds_WrappedResults_ReturnsIds() + { + const string response = """ + { + "PrimaryQueryResult": { + "RelevantResults": { + "Table": { + "Rows": { + "results": [ + { "Cells": { "results": [ { "Key": "ListItemID", "Value": "42" } ] } } + ] + } + } + } + } + } + """; + + var ids = SharePointClient.ParseSearchListItemIds(Parse(response)); + + ids.ShouldBe([42]); + } + + [Fact(DisplayName = "Search: нет совпадений — пустой список, а не исключение")] + public void ParseSearchListItemIds_NoRows_ReturnsEmpty() + { + const string response = """{ "PrimaryQueryResult": { "RelevantResults": { "RowCount": 0, "Table": { "Rows": [] } } } } """; + + SharePointClient.ParseSearchListItemIds(Parse(response)).ShouldBeEmpty(); + } + + [Fact(DisplayName = "Search: неожиданная форма ответа не роняет разбор")] + public void ParseSearchListItemIds_UnexpectedShape_ReturnsEmpty() + { + SharePointClient.ParseSearchListItemIds(Parse("""{ "odata.error": "что-то не так" }""")).ShouldBeEmpty(); + } + + [Fact(DisplayName = "RootFolder/ServerRelativeUrl разбирается для условия Path в KQL")] + public void ParseServerRelativeUrl_ReturnsValue() + { + const string response = """ + { "RootFolder": { "ServerRelativeUrl": "/sites/requests/Lists/Заявка тест — согласование" } } + """; + + SharePointClient.ParseServerRelativeUrl(Parse(response)) + .ShouldBe("/sites/requests/Lists/Заявка тест — согласование"); + } + + [Fact(DisplayName = "Список без RootFolder в ответе — null, а не исключение")] + public void ParseServerRelativeUrl_Missing_ReturnsNull() + { + SharePointClient.ParseServerRelativeUrl(Parse("{}")).ShouldBeNull(); + } +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx index 9ff01f8..ee91a8b 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsPhaseTable.tsx @@ -6,6 +6,7 @@ import { texts } from '../../texts'; import { toEmployeeOption } from '../../utils/employee'; import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks'; import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui'; +import { formatSlaHours } from '../../utils/sla'; export interface IManageRequestsPhaseTableProps { title: string; @@ -26,6 +27,7 @@ export interface IManageRequestsPhaseTableProps { requestRejected: boolean; statusVariant: (status: string) => BadgeVariant; onReassign: (row: ManagedApprovalRow) => void; + onEditSla: (row: ManagedApprovalRow) => void; onRemove: (row: ManagedApprovalRow) => void; onNotify: (row: ManagedApprovalRow) => void; /** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */ @@ -79,6 +81,7 @@ export const ManageRequestsPhaseTable: React.FC requestRejected, statusVariant, onReassign, + onEditSla, onRemove, onNotify, notifyingRowId, @@ -187,6 +190,7 @@ export const ManageRequestsPhaseTable: React.FC {texts.manageRequests.columnPosition} {texts.manageRequests.columnStatus} {texts.manageRequests.columnDate} + {texts.manageRequests.columnSla} {!readOnly && } {!readOnly && } @@ -244,6 +248,10 @@ export const ManageRequestsPhaseTable: React.FC {r.position || texts.common.empty} {r.status || texts.common.empty} {r.date || texts.common.empty} + + {formatSlaHours(r.slaHours)} + {r.slaHours === null && {texts.manageRequests.slaInherited}} + {/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/ переназначить/удалить), одна пара на весь блок (rowSpan): двигают шаг целиком, а не отдельного участника параллельной группы. */} @@ -293,6 +301,12 @@ export const ManageRequestsPhaseTable: React.FC label={texts.manageRequests.reassign} onClick={() => onReassign(r)} /> + onEditSla(r)} + /> = ({ api const [matrixLoading, setMatrixLoading] = React.useState(false); const [matrixError, setMatrixError] = React.useState(); const [modal, setModal] = React.useState(); + const [slaRow, setSlaRow] = React.useState(); const [deleteRow, setDeleteRow] = React.useState(); const [saveError, setSaveError] = React.useState(); const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>(); @@ -348,6 +350,31 @@ export const ManageRequestsScreen: React.FC = ({ api const openReassign = (row: ManagedApprovalRow): void => setModal(row); const closeModal = (): void => setModal(undefined); + const openSla = (row: ManagedApprovalRow): void => setSlaRow(row); + const closeSla = (): void => setSlaRow(undefined); + + // SLA правится так же оптимистично, как переназначение: строка в матрице + // обновляется по ответу сервера, без перезагрузки всей матрицы. + const saveSla = ( + // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», работает SLA типа заявки + slaHours: number | null, + // eslint-disable-next-line @rushstack/no-new-null -- см. выше + askOverdueReason: boolean | null + ): Promise => { + if (!matrix || !slaRow) return Promise.resolve(); + setSaveError(undefined); + const rowId = slaRow.id; + return requestManage + .updateRowSla(matrix, rowId, slaHours, askOverdueReason) + .then(() => { + setMatrix(prev => (prev ? patchRow(prev, rowId, { slaHours, askOverdueReason }) : prev)); + closeSla(); + }) + .catch((e: Error) => { + setSaveError(reportError(e, 'saveSla: updateRowSla')); + throw e; + }); + }; // Кого нельзя выбрать при переназначении — все остальные согласующие/ // исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было @@ -424,7 +451,11 @@ export const ManageRequestsScreen: React.FC = ({ api comment: '', order, allowReassign: false, - phase + phase, + // Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не + // имеет — работает SLA типа заявки, пока его не зададут отдельно. + slaHours: null, + askOverdueReason: null }; const previous = matrix; @@ -548,6 +579,7 @@ export const ManageRequestsScreen: React.FC = ({ api requestRejected={requestRejected} statusVariant={statusVariant} onReassign={openReassign} + onEditSla={openSla} onRemove={askDelete} onNotify={handleNotify} notifyingRowId={notifyingRowId} @@ -564,6 +596,7 @@ export const ManageRequestsScreen: React.FC = ({ api requestRejected={requestRejected} statusVariant={statusVariant} onReassign={openReassign} + onEditSla={openSla} onRemove={askDelete} onNotify={handleNotify} notifyingRowId={notifyingRowId} @@ -590,6 +623,16 @@ export const ManageRequestsScreen: React.FC = ({ api /> )} + {slaRow && ( + + )} + {deleteRow && ( Promise; + onClose: () => void; +} + +/** + * SLA конкретной строки маршрута конкретной заявки. + * + * Правится отдельно от маршрута-шаблона: шаблон задаёт значение на момент + * создания заявки, а здесь его можно поменять для одной уже идущей заявки, не + * трогая остальные. + * + * Пустое поле сохраняется как «не задано» — тогда работает SLA типа заявки. + * Ноль пустотой не считается: это «напоминать сразу». + */ +export const ManageRequestsSlaModal: React.FC = ({ + orderNo, + slaHours, + askOverdueReason, + onSave, + onClose +}) => { + const [hours, setHours] = React.useState(() => slaHoursToInput(slaHours)); + const [ask, setAsk] = React.useState(() => boolToTriState(askOverdueReason)); + const submit = useAsyncSubmit(); + + const handleSubmit = (): void => { + if (!isValidSlaHours(hours)) { + submit.fail(texts.manageRequests.validationSlaHours); + return; + } + submit.run(() => onSave(parseSlaHours(hours), triStateToBool(ask))); + }; + + return ( + + + + setHours(e.target.value)} + placeholder={texts.manageRequests.slaHoursPlaceholder} + /> + + + setForm({ ...form, askOverdueReason: e.target.value as TriState })} + /> + )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index e20aec4..43b1643 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -18,6 +18,15 @@ import { useSharePointFields } from '../../hooks/useSharePointFields'; import { ApprovalRouteApi } from '../../services/api'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists'; import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; +import { + boolToTriState, + isValidSlaHours, + parseSlaHours, + slaHoursToInput, + triStateToBool, + TriState, + TRI_STATE_OPTIONS +} from '../../utils/sla'; export interface IRouteStepFormModalProps { api: ApprovalRouteApi; @@ -47,6 +56,9 @@ interface IFormState { fieldName: string; operation: ComparisonOperator; value: string; + /** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */ + slaHours: string; + askOverdueReason: TriState; } const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] @@ -78,7 +90,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number hasCondition: false, fieldName: '', operation: ComparisonOperator.GreaterThan, - value: '' + value: '', + slaHours: '', + askOverdueReason: '' }; } return { @@ -93,7 +107,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number hasCondition: Boolean(editing.fieldName), fieldName: editing.fieldName ?? '', operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan, - value: editing.value ?? '' + value: editing.value ?? '', + slaHours: slaHoursToInput(editing.slaHours), + askOverdueReason: boolToTriState(editing.askOverdueReason) }; } @@ -164,6 +180,10 @@ export const RouteStepFormModal: React.FC = ({ submit.fail(texts.routeSteps.validationConditionNumber); return; } + if (!isValidSlaHours(form.slaHours)) { + submit.fail(texts.routeSteps.validationSlaHours); + return; + } const payload = { orderNo, @@ -175,7 +195,9 @@ export const RouteStepFormModal: React.FC = ({ headType: form.kind === 'headType' ? form.headType : null, fieldName: form.hasCondition ? form.fieldName.trim() : null, operation: form.hasCondition ? form.operation : null, - value: form.hasCondition ? form.value.trim() : null + value: form.hasCondition ? form.value.trim() : null, + slaHours: parseSlaHours(form.slaHours), + askOverdueReason: triStateToBool(form.askOverdueReason) }; const request = editing @@ -283,6 +305,23 @@ export const RouteStepFormModal: React.FC = ({ /> + + setForm({ ...form, slaHours: e.target.value })} + placeholder={texts.routeSteps.slaHoursPlaceholder} + /> + + +