diff --git a/.gitea/workflows/deploy_backend.yml b/.gitea/workflows/deploy_backend.yml index 5da8b86..481a034 100644 --- a/.gitea/workflows/deploy_backend.yml +++ b/.gitea/workflows/deploy_backend.yml @@ -25,7 +25,7 @@ jobs: run: dotnet restore backend/Tnsu.ApprovalRoute.sln - name: Publish (local) - run: dotnet publish backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o "$LOCAL_PUBLISH" + run: dotnet publish backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o "$LOCAL_PUBLISH" - name: Install cifs-utils run: | diff --git a/README.md b/README.md index 82c2206..05d4a9b 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,10 @@ backend/ Directory.Build.props # общие настройки таргета (net10.0, nullable, etc.) Directory.Packages.props # централизованные версии пакетов Tnsu.ApprovalRoute.sln - src/ - Tnsu.ApprovalRoute.Domain/ # сущности и enum'ы домена - Tnsu.ApprovalRoute.Application/ # сервисы, DTO, валидаторы - Tnsu.ApprovalRoute.Infrastructure/ # EF Core DbContext, миграции, клиент tnsu-portal - Tnsu.ApprovalRoute.Api/ # ASP.NET Core Controllers, Program.cs, авторизация + Tnsu.ApprovalRoute.Domain/ # сущности и enum'ы домена + Tnsu.ApprovalRoute.Application/ # сервисы, DTO, валидаторы + Tnsu.ApprovalRoute.Infrastructure/ # EF Core DbContext, миграции, клиент tnsu-portal + Tnsu.ApprovalRoute.Api/ # ASP.NET Core Controllers, Program.cs, авторизация tests/ Tnsu.ApprovalRoute.IntegrationTests/ frontend/ @@ -61,7 +60,7 @@ npm run build # heft test + package-solution --production ```bash cd backend -dotnet run --project src/Tnsu.ApprovalRoute.Api +dotnet run --project Tnsu.ApprovalRoute.Api ``` Требуется PostgreSQL (строка подключения — `ConnectionStrings:Postgres` в `appsettings.json`). 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 new file mode 100644 index 0000000..27f8132 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs @@ -0,0 +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 new file mode 100644 index 0000000..5d82424 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +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)) + AddEntraAuth(services, configuration, tenantId, audience); + else + 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(); + // Без этого JwtBearerHandler переименовывает короткие имена claim'ов + // из токена в длинные ClaimTypes-URI (upn → ClaimTypes.Upn, + // unique_name → ClaimTypes.Name — а НЕ ClaimTypes.Email!) ещё до + // того, как код увидит ClaimsPrincipal. CurrentUserAccessor ищет + // ровно сырые имена ("upn", "unique_name") — с ремаппингом они не + // совпадали ни с чем, и email инициатора не находился вообще + // (не только у токенов без claim'а email, как этот — у ЛЮБЫХ). + options.MapInboundClaims = false; + }); + + services.AddAuthentication() + .AddJwtBearer(AuthSchemes.AppOnly, options => + { + options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; + options.TokenValidationParameters = validationParameters(); + options.MapInboundClaims = false; + 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/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs rename to backend/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs rename to backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs b/backend/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs rename to backend/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs rename to backend/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs 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/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs rename to backend/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs rename to backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs rename to backend/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/Dockerfile b/backend/Tnsu.ApprovalRoute.Api/Dockerfile new file mode 100644 index 0000000..d32c8de --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Api/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY Directory.Build.props Directory.Packages.props ./ +COPY Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj Tnsu.ApprovalRoute.Domain/ +COPY Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj Tnsu.ApprovalRoute.Application/ +COPY Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj Tnsu.ApprovalRoute.Infrastructure/ +COPY Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj Tnsu.ApprovalRoute.Api/ +RUN dotnet restore Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +COPY Tnsu.ApprovalRoute.Domain/ Tnsu.ApprovalRoute.Domain/ +COPY Tnsu.ApprovalRoute.Application/ Tnsu.ApprovalRoute.Application/ +COPY Tnsu.ApprovalRoute.Infrastructure/ Tnsu.ApprovalRoute.Infrastructure/ +COPY Tnsu.ApprovalRoute.Api/ Tnsu.ApprovalRoute.Api/ +RUN dotnet publish Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Tnsu.ApprovalRoute.Api.dll"] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs rename to backend/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs similarity index 95% rename from backend/src/Tnsu.ApprovalRoute.Api/Program.cs rename to backend/Tnsu.ApprovalRoute.Api/Program.cs index 12d7319..178f2f3 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json b/backend/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json rename to backend/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj rename to backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.Development.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Api/appsettings.Development.json rename to backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json similarity index 71% rename from backend/src/Tnsu.ApprovalRoute.Api/appsettings.json rename to backend/Tnsu.ApprovalRoute.Api/appsettings.json index 12fe82b..8062415 100644 --- a/backend/src/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": "Rk.xDjccnfyjdktybz1" + }, "TnsuPortal": { "BaseUrl": "https://api.tnsu.kz", "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", diff --git a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs similarity index 88% rename from backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs rename to backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index e466d09..3e5732c 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs b/backend/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs rename to backend/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs b/backend/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs rename to backend/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs new file mode 100644 index 0000000..dacb884 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs @@ -0,0 +1,115 @@ +using System.Globalization; +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; + + /// + /// Целое из JSON — с поправкой на то, КАК SharePoint отдаёт Number-колонки. + /// + /// Одного TryGetInt32 здесь мало: у колонки типа Number значение приходит + /// дробным («1.0», а не «1»), а TryGetInt32 на числе с десятичной точкой + /// возвращает false — и поле молча становилось null. На фронтенде этой + /// проблемы нет вовсе: в JavaScript 1.0 и 1 — одно и то же число, поэтому + /// там те же данные читались верно, а на бэкенде терялись. + /// + /// Строку тоже принимаем: с odata=verbose (и у части полей) SharePoint + /// отдаёт числа строками. + /// + private static bool TryGetInt(JsonElement e, string name, out int value) + { + value = 0; + if (!e.TryGetProperty(name, out var v)) return false; + + if (v.ValueKind == JsonValueKind.Number) + { + if (v.TryGetInt32(out value)) return true; + if (!v.TryGetDouble(out var d) || d is < int.MinValue or > int.MaxValue) return false; + value = (int)Math.Round(d); + return true; + } + + if (v.ValueKind != JsonValueKind.String) return false; + if (!double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) + return false; + if (parsed is < int.MinValue or > int.MaxValue) return false; + + value = (int)Math.Round(parsed); + return true; + } + + 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 JsonValueKind.True or JsonValueKind.False) + { + value = v.GetBoolean(); + return true; + } + + // Да/Нет-колонка тоже может прийти строкой («true»/«false») — см. TryGetInt. + return v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out value); + } + + 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 = "На исполнении"; +} 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..9c68e35 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -0,0 +1,261 @@ +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; + + // $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск, + // поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов). + private const string ActiveStatusFilter = + $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; + + 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. Активные строки — прямым $filter по полю ApprovalStatus. + // + // Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) — + // тот же приём, что в request-portal (см. MP_STATUS). На практике он + // строки не нашёл: индекс мог не подхватить именно эту managed property + // для этого списка (она формируется автоматически, и после провижининга + // колонок SLA переиндексация могла ещё не пройти) — сама причина не + // подтверждена, а вот результат — нет. Прямой $filter по полю сработал: + // он не отстаёт от реального состояния списка (не зависит от индексации + // вовсе), и подтверждённой на практике проблемы с ним на этом списке нет. + var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken); + 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); + } + + 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 new file mode 100644 index 0000000..b8992e4 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -0,0 +1,42 @@ +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +// 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, + int? SlaHours = null, + bool? AskOverdueReason = null); + +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, + int? SlaHours = null, + bool? AskOverdueReason = null); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs similarity index 81% rename from backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs rename to backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 296f600..fa58fa8 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs similarity index 64% rename from backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs rename to backend/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index 8a47466..c830a20 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs similarity index 62% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 200c375..5736e17 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs similarity index 95% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 8d6c11a..516efa9 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs similarity index 76% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 1c4ac4b..577edf1 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs similarity index 59% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index ceb8cd6..1aba714 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs similarity index 91% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 8bdbaec..3d210d4 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs rename to backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj rename to backend/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs new file mode 100644 index 0000000..70db213 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -0,0 +1,29 @@ +namespace Tnsu.ApprovalRoute.Domain.Entities; + +public class RequestType +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string PageUrl { get; set; } = string.Empty; + public string? ListId { get; set; } + 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/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs rename to backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs new file mode 100644 index 0000000..665bd01 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -0,0 +1,39 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Domain.Entities; + +public class TemplateRouteStep +{ + public int Id { get; set; } + public int OrderNo { get; set; } + + public string? ExecutorEmail { get; set; } + public string? Position { get; set; } + public ProjectRole? ProjectRole { get; set; } + public HeadType? HeadType { get; set; } + + public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; + 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; } + + public TemplateRoute? Route { get; set; } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs rename to backend/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs rename to backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs rename to backend/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs rename to backend/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj b/backend/Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj rename to backend/Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs similarity index 72% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index fccf5ce..ca7ef6f 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs similarity index 77% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 5aeb2f4..3bf9ee4 100644 --- a/backend/src/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/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/DatabaseMigrationInitializer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/DatabaseMigrationInitializer.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/DatabaseMigrationInitializer.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/DatabaseMigrationInitializer.cs 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/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.Designer.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.Designer.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.Designer.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260803110943_RenameRequestTypeListNameToListId.cs 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/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs similarity index 85% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 60b1783..a3209ad 100644 --- a/backend/src/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..510a7d0 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs @@ -0,0 +1,145 @@ +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. + Task> GetItemsByListUrlAsync( + string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); + + /// Элементы списка, найденного по заголовку (списки согласования ищутся именно так). + Task> GetItemsByListTitleAsync( + string siteUrl, string listTitle, string odataQuery, 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 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/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj similarity index 86% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj rename to backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index 33b62bc..7d5e20c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -3,6 +3,8 @@ + + diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs rename to backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs diff --git a/backend/Tnsu.ApprovalRoute.sln b/backend/Tnsu.ApprovalRoute.sln index ee88a58..25d34e5 100644 --- a/backend/Tnsu.ApprovalRoute.sln +++ b/backend/Tnsu.ApprovalRoute.sln @@ -3,15 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Domain", "Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj", "{67C6FECB-26BF-4A7E-AC40-574F4D88EEAC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Domain", "src\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj", "{67C6FECB-26BF-4A7E-AC40-574F4D88EEAC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Application", "Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj", "{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Application", "src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj", "{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastructure", "Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj", "{46EF4247-8B98-4CC9-A981-186C8D24163C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastructure", "src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj", "{46EF4247-8B98-4CC9-A981-186C8D24163C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" EndProject @@ -92,10 +90,6 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {67C6FECB-26BF-4A7E-AC40-574F4D88EEAC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/backend/src/.vs/VSWorkspaceState.json b/backend/src/.vs/VSWorkspaceState.json deleted file mode 100644 index a35ce30..0000000 --- a/backend/src/.vs/VSWorkspaceState.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "ExpandedNodes": [ - "", - "\\Tnsu.ApprovalRoute.Api", - "\\Tnsu.ApprovalRoute.Application" - ], - "SelectedNode": "\\Tnsu.ApprovalRoute.Application", - "PreviewInSolutionExplorer": false -} \ No newline at end of file diff --git a/backend/src/.vs/slnx.sqlite b/backend/src/.vs/slnx.sqlite deleted file mode 100644 index 43f81fe..0000000 Binary files a/backend/src/.vs/slnx.sqlite and /dev/null differ diff --git a/backend/src/.vs/src.slnx/FileContentIndex/c41e12f7-4a3d-4c40-9653-41a24091003e.vsidx b/backend/src/.vs/src.slnx/FileContentIndex/c41e12f7-4a3d-4c40-9653-41a24091003e.vsidx deleted file mode 100644 index 78115d3..0000000 Binary files a/backend/src/.vs/src.slnx/FileContentIndex/c41e12f7-4a3d-4c40-9653-41a24091003e.vsidx and /dev/null differ diff --git a/backend/src/.vs/src.slnx/FileContentIndex/f9a35f81-13ae-4547-9684-90f51c188103.vsidx b/backend/src/.vs/src.slnx/FileContentIndex/f9a35f81-13ae-4547-9684-90f51c188103.vsidx deleted file mode 100644 index d4cbc0d..0000000 Binary files a/backend/src/.vs/src.slnx/FileContentIndex/f9a35f81-13ae-4547-9684-90f51c188103.vsidx and /dev/null differ diff --git a/backend/src/.vs/src.slnx/config/applicationhost.config b/backend/src/.vs/src.slnx/config/applicationhost.config deleted file mode 100644 index 223dc4c..0000000 --- a/backend/src/.vs/src.slnx/config/applicationhost.config +++ /dev/null @@ -1,1011 +0,0 @@ - - - - - - - - -
-
-
-
-
-
-
-
- - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- -
-
-
-
-
- -
-
-
- -
-
- -
-
- -
-
-
- - -
-
-
-
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/src/.vs/src.slnx/v18/.wsuo b/backend/src/.vs/src.slnx/v18/.wsuo deleted file mode 100644 index 0476f63..0000000 Binary files a/backend/src/.vs/src.slnx/v18/.wsuo and /dev/null differ diff --git a/backend/src/.vs/src.slnx/v18/DocumentLayout.backup.json b/backend/src/.vs/src.slnx/v18/DocumentLayout.backup.json deleted file mode 100644 index 8712180..0000000 --- a/backend/src/.vs/src.slnx/v18/DocumentLayout.backup.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "Version": 1, - "WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\", - "Documents": [ - { - "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}", - "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}" - } - ], - "DocumentGroupContainers": [ - { - "Orientation": 0, - "VerticalTabListWidth": 256, - "DocumentGroups": [ - { - "DockedWidth": 200, - "SelectedChildIndex": 0, - "Children": [ - { - "$type": "Document", - "DocumentIndex": 0, - "Title": "appsettings.json", - "DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json", - "RelativeDocumentMoniker": "Tnsu.ApprovalRoute.Api\\appsettings.json", - "ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json", - "RelativeToolTip": "Tnsu.ApprovalRoute.Api\\appsettings.json", - "ViewState": "AgIAAAAAAAAAAAAAAAAAABUAAAAXAAAAAAAAAA==", - "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|", - "WhenOpened": "2026-07-28T05:52:49.686Z", - "EditorCaption": "" - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/backend/src/.vs/src.slnx/v18/DocumentLayout.json b/backend/src/.vs/src.slnx/v18/DocumentLayout.json deleted file mode 100644 index 8712180..0000000 --- a/backend/src/.vs/src.slnx/v18/DocumentLayout.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "Version": 1, - "WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\", - "Documents": [ - { - "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}", - "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}" - } - ], - "DocumentGroupContainers": [ - { - "Orientation": 0, - "VerticalTabListWidth": 256, - "DocumentGroups": [ - { - "DockedWidth": 200, - "SelectedChildIndex": 0, - "Children": [ - { - "$type": "Document", - "DocumentIndex": 0, - "Title": "appsettings.json", - "DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json", - "RelativeDocumentMoniker": "Tnsu.ApprovalRoute.Api\\appsettings.json", - "ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json", - "RelativeToolTip": "Tnsu.ApprovalRoute.Api\\appsettings.json", - "ViewState": "AgIAAAAAAAAAAAAAAAAAABUAAAAXAAAAAAAAAA==", - "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|", - "WhenOpened": "2026-07-28T05:52:49.686Z", - "EditorCaption": "" - } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs deleted file mode 100644 index 331bf3d..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Tnsu.ApprovalRoute.Api.Auth; - -public static class AuthSchemes -{ - public const string Dev = "Dev"; -} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs deleted file mode 100644 index e72b17c..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; - -namespace Tnsu.ApprovalRoute.Api.Auth; - -public static class AuthSetup -{ - 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}"] - }; - }); - } - else - { - services.AddAuthentication(AuthSchemes.Dev) - .AddScheme(AuthSchemes.Dev, _ => { }); - } - - services.AddAuthorization(); - - return services; - } -} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Dockerfile b/backend/src/Tnsu.ApprovalRoute.Api/Dockerfile deleted file mode 100644 index a9f4110..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Api/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY Directory.Build.props Directory.Packages.props ./ -COPY src/Tnsu.ApprovalRoute.Domain/Tnsu.ApprovalRoute.Domain.csproj src/Tnsu.ApprovalRoute.Domain/ -COPY src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj src/Tnsu.ApprovalRoute.Application/ -COPY src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj src/Tnsu.ApprovalRoute.Infrastructure/ -COPY src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj src/Tnsu.ApprovalRoute.Api/ -RUN dotnet restore src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -COPY src/ src/ -RUN dotnet publish src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o /app/publish /p:UseAppHost=false - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* -COPY --from=build /app/publish . -ENV ASPNETCORE_URLS=http://+:8080 -EXPOSE 8080 -ENTRYPOINT ["dotnet", "Tnsu.ApprovalRoute.Api.dll"] diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs deleted file mode 100644 index bc44c5a..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Tnsu.ApprovalRoute.Application.RequestTypes; - -public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListId, string? SiteUrl, string Section); - -public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section); - -public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section); - -public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListId, string? SiteUrl, string? Section); diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs deleted file mode 100644 index b47fa57..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Tnsu.ApprovalRoute.Domain.Entities; - -public class RequestType -{ - public int Id { get; set; } - public string Name { get; set; } = string.Empty; - public string PageUrl { get; set; } = string.Empty; - public string? ListId { get; set; } - public string? SiteUrl { get; set; } - public string Section { get; set; } = string.Empty; - - public ICollection Routes { get; set; } = new List(); -} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs deleted file mode 100644 index ce5a6be..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - -namespace Tnsu.ApprovalRoute.Domain.Entities; - -public class TemplateRouteStep -{ - public int Id { get; set; } - public int OrderNo { get; set; } - - public string? ExecutorEmail { get; set; } - public string? Position { get; set; } - public ProjectRole? ProjectRole { get; set; } - public HeadType? HeadType { get; set; } - - public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; - public int RouteId { get; set; } - public bool AllowReassignToOtherApprovers { get; set; } - - public string? FieldName { get; set; } - public ComparisonOperator? Operation { get; set; } - public string? Value { get; set; } - - public TemplateRoute? Route { get; set; } -} 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..3467c06 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -0,0 +1,293 @@ +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", object? 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); + + // Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок + // отвечает так же: по значению статуса, а не по признаку решённости. + var activeRows = allRows + .Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is + "На согласовании" or "На исполнении") + .ToList(); + sharePoint + .Setup(x => x.GetItemsByListTitleAsync( + SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("ApprovalStatus 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); + } + + // SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом + // разбор спотыкался: JsonElement.TryGetInt32 на числе с десятичной точкой + // возвращает false, поле молча становилось null и SLA строки терялся. + // На фронтенде проблемы не было — в JavaScript 1.0 и 1 неразличимы. + [Fact(DisplayName = "SLA строки читается, даже когда SharePoint отдаёт его дробным числом")] + public async Task RowSla_WrittenAsDecimalNumber_IsStillRead() + { + var listUrl = await CreateTypeWithSlaAsync("Sla decimal", slaHours: 100); + // По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов. + var service = BuildService(listUrl, + // Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и + // пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не + // воспроизводит. + [Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)], + 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/Tnsu.ApprovalRoute.IntegrationTests.csproj b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj index 4b9680b..74da7e1 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/docker-compose.yml b/docker-compose.yml index a664dbb..8112d3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: api: build: context: ./backend - dockerfile: src/Tnsu.ApprovalRoute.Api/Dockerfile + dockerfile: Tnsu.ApprovalRoute.Api/Dockerfile container_name: tnsu-approval-route-api restart: unless-stopped depends_on: diff --git a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx index 36947ce..6a6c119 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/ManageRequestsApproverModal.tsx @@ -3,30 +3,59 @@ import { Employee } from '../../models'; import { texts } from '../../texts'; import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; -import { Field, FormGrid, FormModal, SearchableSelect } from '../ui'; +import { + boolToTriState, + isValidSlaHours, + parseSlaHours, + slaHoursToInput, + triStateToBool, + TriState, + TRI_STATE_OPTIONS +} from '../../utils/sla'; +import { Field, FormGrid, FormModal, SearchableSelect, Select, TextInput } from '../ui'; export interface IManageRequestsApproverModalProps { title: string; initialEmail?: string; + /** SLA строки в часах на момент открытия. null — в строке не задано, работает SLA типа заявки. */ + // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», это не 0 + initialSlaHours: number | null; + /** Опрос причины просрочки по строке. null — берётся от типа заявки. */ + // eslint-disable-next-line @rushstack/no-new-null -- см. initialSlaHours + initialAskOverdueReason: boolean | null; employees: Employee[]; employeesLoading: boolean; /** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */ excludedEmails: string[]; - onSave: (email: string, position: string) => Promise; + onSave: ( + email: string, + position: string, + // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано» + slaHours: number | null, + // eslint-disable-next-line @rushstack/no-new-null -- null = «не задано» + askOverdueReason: boolean | null + ) => Promise; onClose: () => void; } /** - * Переназначение согласующего/исполнителя существующего шага (см. - * ManageRequestsScreen). Должность НЕ вводится — берётся из справочника - * сотрудников (api.tnsu.kz/Employee/All, см. Employee.position в - * models/dictionaries.ts) на момент сохранения, отдельного поля для неё - * в этой форме нет. Добавление НОВОГО шага через модалку не идёт — см. + * Правка существующего шага: кто согласует и какой у шага SLA. + * + * Одна форма на оба вида правки намеренно — раньше SLA жил в отдельной модалке + * со своей кнопкой-флажком в строке, и получалось два похожих диалога и лишняя + * иконка в и без того плотном ряду действий. Правки применяются одним + * сохранением (см. saveModal в ManageRequestsScreen). + * + * Должность НЕ вводится — берётся из справочника сотрудников + * (api.tnsu.kz/Employee/All, см. Employee.position в models/dictionaries.ts) на + * момент сохранения. Добавление НОВОГО шага через модалку не идёт — см. * плашку-пикер под таблицей в ManageRequestsPhaseTable. */ export const ManageRequestsApproverModal: React.FC = ({ title, initialEmail = '', + initialSlaHours, + initialAskOverdueReason, employees, employeesLoading, excludedEmails, @@ -34,6 +63,10 @@ export const ManageRequestsApproverModal: React.FC { const [email, setEmail] = React.useState(initialEmail); + const [slaHours, setSlaHours] = React.useState(() => slaHoursToInput(initialSlaHours)); + const [askOverdueReason, setAskOverdueReason] = React.useState( + () => boolToTriState(initialAskOverdueReason) + ); const submit = useAsyncSubmit(); // Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат @@ -51,8 +84,14 @@ export const ManageRequestsApproverModal: React.FC onSave(trimmedEmail, position)); + submit.run(() => + onSave(trimmedEmail, position, parseSlaHours(slaHours), triStateToBool(askOverdueReason)) + ); }; return ( @@ -69,6 +108,22 @@ export const ManageRequestsApproverModal: React.FC + + setSlaHours(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} + /> + + +