Sla #11
@@ -25,7 +25,7 @@ jobs:
|
|||||||
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
||||||
|
|
||||||
- name: Publish (local)
|
- 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
|
- name: Install cifs-utils
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ backend/
|
|||||||
Directory.Build.props # общие настройки таргета (net10.0, nullable, etc.)
|
Directory.Build.props # общие настройки таргета (net10.0, nullable, etc.)
|
||||||
Directory.Packages.props # централизованные версии пакетов
|
Directory.Packages.props # централизованные версии пакетов
|
||||||
Tnsu.ApprovalRoute.sln
|
Tnsu.ApprovalRoute.sln
|
||||||
src/
|
Tnsu.ApprovalRoute.Domain/ # сущности и enum'ы домена
|
||||||
Tnsu.ApprovalRoute.Domain/ # сущности и enum'ы домена
|
Tnsu.ApprovalRoute.Application/ # сервисы, DTO, валидаторы
|
||||||
Tnsu.ApprovalRoute.Application/ # сервисы, DTO, валидаторы
|
Tnsu.ApprovalRoute.Infrastructure/ # EF Core DbContext, миграции, клиент tnsu-portal
|
||||||
Tnsu.ApprovalRoute.Infrastructure/ # EF Core DbContext, миграции, клиент tnsu-portal
|
Tnsu.ApprovalRoute.Api/ # ASP.NET Core Controllers, Program.cs, авторизация
|
||||||
Tnsu.ApprovalRoute.Api/ # ASP.NET Core Controllers, Program.cs, авторизация
|
|
||||||
tests/
|
tests/
|
||||||
Tnsu.ApprovalRoute.IntegrationTests/
|
Tnsu.ApprovalRoute.IntegrationTests/
|
||||||
frontend/
|
frontend/
|
||||||
@@ -61,7 +60,7 @@ npm run build # heft test + package-solution --production
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
dotnet run --project src/Tnsu.ApprovalRoute.Api
|
dotnet run --project Tnsu.ApprovalRoute.Api
|
||||||
```
|
```
|
||||||
|
|
||||||
Требуется PostgreSQL (строка подключения — `ConnectionStrings:Postgres` в `appsettings.json`).
|
Требуется PostgreSQL (строка подключения — `ConnectionStrings:Postgres` в `appsettings.json`).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSchemes
|
||||||
|
{
|
||||||
|
/// <summary>Локальная разработка и тесты — Entra не настроен (см. AuthSetup).</summary>
|
||||||
|
public const string Dev = "Dev";
|
||||||
|
|
||||||
|
/// <summary>Токен пользователя (делегированный) — обычная работа админки.</summary>
|
||||||
|
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Токен приложения (client_credentials) — вызовы без участия пользователя,
|
||||||
|
/// например Power Automate, который по расписанию забирает напоминания.
|
||||||
|
/// </summary>
|
||||||
|
public const string AppOnly = "AppOnly";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AuthPolicies
|
||||||
|
{
|
||||||
|
/// <summary>Только пользовательские токены.</summary>
|
||||||
|
public const string Delegated = "Delegated";
|
||||||
|
|
||||||
|
/// <summary>Только токены приложений.</summary>
|
||||||
|
public const string AppOnly = "AppOnlyPolicy";
|
||||||
|
|
||||||
|
/// <summary>Любой из двух — политика по умолчанию для всех контроллеров.</summary>
|
||||||
|
public const string Any = "AnyAuth";
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Две схемы аутентификации вместо одной: помимо пользовательского токена
|
||||||
|
/// (админка) API должен принимать токен ПРИЛОЖЕНИЯ — эндпоинт напоминаний
|
||||||
|
/// вызывает Power Automate по расписанию, там нет ни пользователя, ни
|
||||||
|
/// интерактивного входа.
|
||||||
|
///
|
||||||
|
/// Сделано по образцу api-tnsu-portal/AuthRegistration.cs, включая настройки
|
||||||
|
/// Entra — приложение регистрируется то же самое.
|
||||||
|
/// </summary>
|
||||||
|
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<string[]>() ?? [];
|
||||||
|
|
||||||
|
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<ILoggerFactory>()
|
||||||
|
.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<AuthenticationSchemeOptions, DevAuthHandler>(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)!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Просроченные шаги согласования и исполнения на текущий момент.
|
||||||
|
///
|
||||||
|
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
||||||
|
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
||||||
|
/// открыть под пользователем, чтобы посмотреть, что сервис реально отдаёт.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicies.Any)]
|
||||||
|
[Route("api/reminders")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RemindersController(ReminderService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||||
|
(await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
+2
@@ -9,6 +9,7 @@ using Scalar.AspNetCore;
|
|||||||
using Tnsu.ApprovalRoute.Api.Auth;
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
using Tnsu.ApprovalRoute.Api.Filters;
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
using Tnsu.ApprovalRoute.Application;
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ builder.Services.AddCors(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
|
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
||||||
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||||
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||||
builder.Services.AddOpenApi(options =>
|
builder.Services.AddOpenApi(options =>
|
||||||
+8
-1
@@ -10,7 +10,8 @@
|
|||||||
},
|
},
|
||||||
"Entra": {
|
"Entra": {
|
||||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
|
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"AllowedAppIds": []
|
||||||
},
|
},
|
||||||
"Dev": {
|
"Dev": {
|
||||||
"DefaultUserEmail": "dev@tnsu.kz"
|
"DefaultUserEmail": "dev@tnsu.kz"
|
||||||
@@ -21,6 +22,12 @@
|
|||||||
"https://localhost:4321"
|
"https://localhost:4321"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SharePoint": {
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"CertificateSettingName": "AzurePfx",
|
||||||
|
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
|
||||||
|
},
|
||||||
"TnsuPortal": {
|
"TnsuPortal": {
|
||||||
"BaseUrl": "https://api.tnsu.kz",
|
"BaseUrl": "https://api.tnsu.kz",
|
||||||
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
+2
@@ -1,6 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
@@ -11,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddScoped<ReminderService>();
|
||||||
services.AddScoped<RequestTypeService>();
|
services.AddScoped<RequestTypeService>();
|
||||||
services.AddScoped<TemplateRouteService>();
|
services.AddScoped<TemplateRouteService>();
|
||||||
services.AddScoped<TemplateRouteStepService>();
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||||
|
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||||
|
/// именно он эти списки и создаёт.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Целое из JSON — с поправкой на то, КАК SharePoint отдаёт Number-колонки.
|
||||||
|
///
|
||||||
|
/// Одного TryGetInt32 здесь мало: у колонки типа Number значение приходит
|
||||||
|
/// дробным («1.0», а не «1»), а TryGetInt32 на числе с десятичной точкой
|
||||||
|
/// возвращает false — и поле молча становилось null. На фронтенде этой
|
||||||
|
/// проблемы нет вовсе: в JavaScript 1.0 и 1 — одно и то же число, поэтому
|
||||||
|
/// там те же данные читались верно, а на бэкенде терялись.
|
||||||
|
///
|
||||||
|
/// Строку тоже принимаем: с odata=verbose (и у части полей) SharePoint
|
||||||
|
/// отдаёт числа строками.
|
||||||
|
/// </summary>
|
||||||
|
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 = "На исполнении";
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
||||||
|
/// <param name="HoursOverdue">
|
||||||
|
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||||
|
/// Выходные не учитываются — считаются календарные часы.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
|
||||||
|
/// <param name="AskOverdueReason">
|
||||||
|
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||||
|
/// наследованием (шаг перекрывает тип заявки).
|
||||||
|
/// </param>
|
||||||
|
public sealed record ReminderDto(
|
||||||
|
string Email,
|
||||||
|
int HoursOverdue,
|
||||||
|
string Url,
|
||||||
|
bool AskOverdueReason,
|
||||||
|
int RequestTypeId,
|
||||||
|
string RequestTypeName,
|
||||||
|
int RequestId);
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кто прямо сейчас просрочил свой шаг согласования или исполнения.
|
||||||
|
///
|
||||||
|
/// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный
|
||||||
|
/// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано —
|
||||||
|
/// рассылкой и защитой от повторов занимается вызывающая сторона (Power
|
||||||
|
/// Automate по расписанию).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ISharePointClient sharePoint,
|
||||||
|
IOptions<RemindersOptions> options,
|
||||||
|
ILogger<ReminderService> 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<Result<IReadOnlyList<ReminderDto>>> 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<ReminderDto>();
|
||||||
|
|
||||||
|
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<IReadOnlyList<ReminderDto>>(reminders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ReminderDto>> 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<ReminderDto>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Момент, с которого шаг считается активным, — начало отсчёта SLA.
|
||||||
|
///
|
||||||
|
/// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
|
||||||
|
/// точечное обновление статуса, дату оно не пишет), поэтому момент выводится
|
||||||
|
/// из соседних строк:
|
||||||
|
/// • первый шаг согласования — дата создания заявки;
|
||||||
|
/// • первый шаг исполнения — последнее решение по фазе согласования
|
||||||
|
/// (фаза исполнения активируется только когда согласование пройдено целиком);
|
||||||
|
/// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы.
|
||||||
|
///
|
||||||
|
/// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить
|
||||||
|
/// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг
|
||||||
|
/// активируется только после решения последнего из них.
|
||||||
|
///
|
||||||
|
/// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не
|
||||||
|
/// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по
|
||||||
|
/// условию появления).
|
||||||
|
/// </summary>
|
||||||
|
private static DateTimeOffset? ActivatedAt(
|
||||||
|
ApprovalRow row, IReadOnlyList<ApprovalRow> 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<ApprovalRow> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
||||||
|
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
||||||
|
/// откроется панелью поверх раздела (rpmode=approve).
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
|
||||||
|
/// <summary>«Заявка на приём — данные» → «Заявка на приём — согласование».</summary>
|
||||||
|
private static string? ApprovalListTitleFor(string dataListTitle)
|
||||||
|
{
|
||||||
|
var index = dataListTitle.LastIndexOf('—');
|
||||||
|
if (index < 0) return null;
|
||||||
|
|
||||||
|
var suffix = dataListTitle[(index + 1)..].Trim();
|
||||||
|
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? $"{dataListTitle[..index].TrimEnd()} — согласование"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string RowSelect =
|
||||||
|
"$select=Id,ApprovalStatus,StepOrder,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||||
|
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ApprovalRow>> 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<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
||||||
|
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = new List<ApprovalRow>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Даты создания заявок — начало отсчёта для первого шага согласования.</summary>
|
||||||
|
private async Task<Dictionary<int, DateTimeOffset>> ReadCreatedAsync(
|
||||||
|
string siteUrl, string listUrl, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var created = new Dictionary<int, DateTimeOffset>();
|
||||||
|
|
||||||
|
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<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||||||
|
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
public sealed class RemindersOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Шаблон ссылки на заявку. Подстановки: {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.
|
||||||
|
///
|
||||||
|
/// Вынесено в конфигурацию, чтобы смена разметки ссылок в портале правилась
|
||||||
|
/// настройкой, а не пересборкой сервиса.
|
||||||
|
/// </summary>
|
||||||
|
public string RequestUrlTemplate { get; set; } =
|
||||||
|
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
+13
-3
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
{
|
{
|
||||||
var items = await db.RequestTypes.AsNoTracking()
|
var items = await db.RequestTypes.AsNoTracking()
|
||||||
.OrderBy(x => x.Name)
|
.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);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||||
@@ -39,7 +39,9 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
PageUrl = request.PageUrl,
|
PageUrl = request.PageUrl,
|
||||||
ListId = request.ListId,
|
ListId = request.ListId,
|
||||||
SiteUrl = request.SiteUrl,
|
SiteUrl = request.SiteUrl,
|
||||||
Section = request.Section
|
Section = request.Section,
|
||||||
|
SlaHours = request.SlaHours,
|
||||||
|
AskOverdueReason = request.AskOverdueReason
|
||||||
};
|
};
|
||||||
db.RequestTypes.Add(entity);
|
db.RequestTypes.Add(entity);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -62,6 +64,8 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
entity.ListId = request.ListId;
|
entity.ListId = request.ListId;
|
||||||
entity.SiteUrl = request.SiteUrl;
|
entity.SiteUrl = request.SiteUrl;
|
||||||
entity.Section = request.Section;
|
entity.Section = request.Section;
|
||||||
|
entity.SlaHours = request.SlaHours;
|
||||||
|
entity.AskOverdueReason = request.AskOverdueReason;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok(ToDto(entity));
|
return Result.Ok(ToDto(entity));
|
||||||
@@ -85,6 +89,11 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
entity.ListId = listId;
|
entity.ListId = listId;
|
||||||
entity.SiteUrl = siteUrl;
|
entity.SiteUrl = siteUrl;
|
||||||
entity.Section = request.Section ?? entity.Section;
|
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);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok(ToDto(entity));
|
return Result.Ok(ToDto(entity));
|
||||||
@@ -102,5 +111,6 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
return Result.Ok();
|
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);
|
||||||
}
|
}
|
||||||
+18
@@ -12,6 +12,12 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
|
|||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||||
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
.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<Update
|
|||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||||
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
.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<PatchRe
|
|||||||
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
||||||
When(x => x.Section is not null, () =>
|
When(x => x.Section is not null, () =>
|
||||||
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
|
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+17
-4
@@ -2,6 +2,11 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
||||||
|
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
||||||
|
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||||
|
// записей, чтобы не ломать существующие позиционные вызовы.
|
||||||
|
|
||||||
public sealed record TemplateRouteStepDto(
|
public sealed record TemplateRouteStepDto(
|
||||||
int Id,
|
int Id,
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
@@ -15,7 +20,9 @@ public sealed record TemplateRouteStepDto(
|
|||||||
string? FieldName,
|
string? FieldName,
|
||||||
ComparisonOperator? Operation,
|
ComparisonOperator? Operation,
|
||||||
string? Value,
|
string? Value,
|
||||||
string? Warning = null);
|
string? Warning = null,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
public sealed record CreateTemplateRouteStepRequest(
|
public sealed record CreateTemplateRouteStepRequest(
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
@@ -27,7 +34,9 @@ public sealed record CreateTemplateRouteStepRequest(
|
|||||||
bool AllowReassignToOtherApprovers,
|
bool AllowReassignToOtherApprovers,
|
||||||
string? FieldName,
|
string? FieldName,
|
||||||
ComparisonOperator? Operation,
|
ComparisonOperator? Operation,
|
||||||
string? Value);
|
string? Value,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
public sealed record UpdateTemplateRouteStepRequest(
|
public sealed record UpdateTemplateRouteStepRequest(
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
@@ -39,7 +48,9 @@ public sealed record UpdateTemplateRouteStepRequest(
|
|||||||
bool AllowReassignToOtherApprovers,
|
bool AllowReassignToOtherApprovers,
|
||||||
string? FieldName,
|
string? FieldName,
|
||||||
ComparisonOperator? Operation,
|
ComparisonOperator? Operation,
|
||||||
string? Value);
|
string? Value,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
public sealed record PatchTemplateRouteStepRequest(
|
public sealed record PatchTemplateRouteStepRequest(
|
||||||
int? OrderNo,
|
int? OrderNo,
|
||||||
@@ -51,4 +62,6 @@ public sealed record PatchTemplateRouteStepRequest(
|
|||||||
bool? AllowReassignToOtherApprovers,
|
bool? AllowReassignToOtherApprovers,
|
||||||
string? FieldName,
|
string? FieldName,
|
||||||
ComparisonOperator? Operation,
|
ComparisonOperator? Operation,
|
||||||
string? Value);
|
string? Value,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
+11
-3
@@ -16,7 +16,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
.OrderBy(x => x.OrderNo)
|
.OrderBy(x => x.OrderNo)
|
||||||
.Select(x => new TemplateRouteStepDto(
|
.Select(x => new TemplateRouteStepDto(
|
||||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
|
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);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||||
@@ -73,7 +73,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
|
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
|
||||||
FieldName = r.FieldName,
|
FieldName = r.FieldName,
|
||||||
Operation = r.Operation,
|
Operation = r.Operation,
|
||||||
Value = r.Value
|
Value = r.Value,
|
||||||
|
SlaHours = r.SlaHours,
|
||||||
|
AskOverdueReason = r.AskOverdueReason
|
||||||
};
|
};
|
||||||
db.TemplateRouteSteps.Add(entity);
|
db.TemplateRouteSteps.Add(entity);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
@@ -125,6 +127,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
entity.FieldName = r.FieldName;
|
entity.FieldName = r.FieldName;
|
||||||
entity.Operation = r.Operation;
|
entity.Operation = r.Operation;
|
||||||
entity.Value = r.Value;
|
entity.Value = r.Value;
|
||||||
|
entity.SlaHours = r.SlaHours;
|
||||||
|
entity.AskOverdueReason = r.AskOverdueReason;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
@@ -190,6 +194,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
entity.FieldName = fieldName;
|
entity.FieldName = fieldName;
|
||||||
entity.Operation = operation;
|
entity.Operation = operation;
|
||||||
entity.Value = value;
|
entity.Value = value;
|
||||||
|
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
|
||||||
|
// наследованию SLA от типа заявки можно через PUT.
|
||||||
|
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||||
|
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, 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(
|
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||||
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
||||||
e.FieldName, e.Operation, e.Value);
|
e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason);
|
||||||
}
|
}
|
||||||
+12
@@ -14,6 +14,12 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
|
|||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||||
.WithMessage("Поля условия (field_name, operation, 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)
|
RuleFor(x => x)
|
||||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||||
|
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||||
|
// ноль допустим — это осознанное «напоминать сразу».
|
||||||
|
RuleFor(x => x.SlaHours)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.SlaHours is not null)
|
||||||
|
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+14
-1
@@ -2,13 +2,26 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
/// <param name="SlaHours">
|
||||||
|
/// ДЕЙСТВУЮЩИЙ SLA шага в часах — наследование уже применено: значение шага, а
|
||||||
|
/// если на шаге не задано, то значение типа заявки. null означает, что SLA не
|
||||||
|
/// настроен нигде, то есть напоминаний по этому шагу не будет.
|
||||||
|
/// Резолв отдаёт именно итоговое значение, чтобы вызывающая сторона
|
||||||
|
/// (request-portal) просто записала его в строку списка согласования и не
|
||||||
|
/// повторяла у себя правило наследования.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AskOverdueReason">
|
||||||
|
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
|
||||||
|
/// </param>
|
||||||
public sealed record ResolvedRouteStepDto(
|
public sealed record ResolvedRouteStepDto(
|
||||||
int Id,
|
int Id,
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
string? ExecutorEmail,
|
string? ExecutorEmail,
|
||||||
string? Position,
|
string? Position,
|
||||||
bool AllowReassignToOtherApprovers,
|
bool AllowReassignToOtherApprovers,
|
||||||
string? Warning = null);
|
string? Warning = null,
|
||||||
|
int? SlaHours = null,
|
||||||
|
bool? AskOverdueReason = null);
|
||||||
|
|
||||||
public sealed record ResolveTemplateRouteResultDto(
|
public sealed record ResolveTemplateRouteResultDto(
|
||||||
int RouteId,
|
int RouteId,
|
||||||
+22
-5
@@ -129,8 +129,11 @@ public sealed class TemplateRouteService(
|
|||||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
|
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<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||||
|
|
||||||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||||
@@ -168,7 +171,8 @@ public sealed class TemplateRouteService(
|
|||||||
|
|
||||||
var steps = new List<ResolvedRouteStepDto>();
|
var steps = new List<ResolvedRouteStepDto>();
|
||||||
foreach (var step in matchingSteps)
|
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);
|
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||||
|
|
||||||
@@ -212,7 +216,8 @@ public sealed class TemplateRouteService(
|
|||||||
|
|
||||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, CancellationToken cancellationToken)
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string? resolvedEmail;
|
string? resolvedEmail;
|
||||||
string? warning = null;
|
string? warning = null;
|
||||||
@@ -277,7 +282,19 @@ public sealed class TemplateRouteService(
|
|||||||
resolvedPosition = resolvedEmployee?.Position;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SLA по умолчанию для ВСЕХ шагов заявок этого типа, в часах. Отсчёт идёт от
|
||||||
|
/// активации шага, а не от создания заявки (см. эндпоинт напоминаний).
|
||||||
|
/// null — SLA не настроен, напоминания по этому типу не строятся.
|
||||||
|
/// Значение на шаге (<see cref="TemplateRouteStep.SlaHours"/>) перекрывает это.
|
||||||
|
/// </summary>
|
||||||
|
public int? SlaHours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спрашивать ли у согласующего причину просрочки — значение по умолчанию для
|
||||||
|
/// всех шагов заявок этого типа. null — не задано.
|
||||||
|
/// Отдаётся в ответе эндпоинта напоминаний, чтобы вызывающая сторона решила,
|
||||||
|
/// запрашивать причину или просто напомнить.
|
||||||
|
/// </summary>
|
||||||
|
public bool? AskOverdueReason { get; set; }
|
||||||
|
|
||||||
|
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SLA этого шага в часах — перекрывает <see cref="RequestType.SlaHours"/>.
|
||||||
|
/// null означает ИМЕННО «не задано, наследовать от типа заявки», а 0 —
|
||||||
|
/// осознанное «напоминать сразу», поэтому поле обязано быть nullable и не
|
||||||
|
/// должно получать значение по умолчанию.
|
||||||
|
/// </summary>
|
||||||
|
public int? SlaHours { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Спрашивать ли причину просрочки на этом шаге — перекрывает
|
||||||
|
/// <see cref="RequestType.AskOverdueReason"/>. Здесь тоже три состояния:
|
||||||
|
/// null — наследовать от типа, true/false — явный выбор.
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
+7
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure;
|
namespace Tnsu.ApprovalRoute.Infrastructure;
|
||||||
@@ -20,6 +21,12 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention());
|
||||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||||
|
|
||||||
|
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
|
||||||
|
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
|
||||||
|
// время жизни процесса (см. DbCertificateProvider).
|
||||||
|
services.AddSingleton<ICertificateProvider, DbCertificateProvider>();
|
||||||
|
services.AddHttpClient<ISharePointClient, SharePointClient>();
|
||||||
|
|
||||||
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||||
services.AddHttpClient<TnsuPortalTokenProvider>();
|
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||||
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
|
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
|
||||||
+12
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
@@ -8,9 +9,20 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
|
public DbSet<AppSetting> Settings => Set<AppSetting>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
modelBuilder.Entity<AppSetting>(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<RequestType>(e =>
|
modelBuilder.Entity<RequestType>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Настройка, которую нельзя держать в appsettings — сейчас это единственный
|
||||||
|
/// случай: PFX-сертификат для похода в SharePoint (option_name = 'AzurePfx').
|
||||||
|
///
|
||||||
|
/// Живёт в Infrastructure, а не в Domain, намеренно: к предметной области
|
||||||
|
/// маршрутов согласования отношения не имеет, это способ хранения секрета.
|
||||||
|
///
|
||||||
|
/// Форма таблицы повторяет settings в api-tnsu-portal (option_name + file_data),
|
||||||
|
/// чтобы копию сертификата можно было перенести запросом один в один, без
|
||||||
|
/// преобразований.
|
||||||
|
/// </summary>
|
||||||
|
public class AppSetting
|
||||||
|
{
|
||||||
|
/// <summary>Ключ настройки. Для сертификата SharePoint — 'AzurePfx'.</summary>
|
||||||
|
public string OptionName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Двоичное содержимое (для 'AzurePfx' — сам .pfx как есть).</summary>
|
||||||
|
public byte[]? FileData { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Текстовое значение — для настроек, которым не нужен файл.</summary>
|
||||||
|
public string? Value { get; set; }
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
|
[Migration("20260812091325_AddSlaSettings")]
|
||||||
|
partial class AddSlaSettings
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("PageUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("page_url");
|
||||||
|
|
||||||
|
b.Property<string>("Section")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("section");
|
||||||
|
|
||||||
|
b.Property<string>("SiteUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)")
|
||||||
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_request_types");
|
||||||
|
|
||||||
|
b.ToTable("request_types", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("ByProject")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("by_project");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CompanyId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DepartmentId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
|
b.Property<int>("RequestTypeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
|
b.Property<bool>("RequiresControl")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("requires_control");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
|
b.ToTable("template_routes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
|
b.Property<string>("ExecutorEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("FieldName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("field_name");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
|
b.Property<string>("Operation")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNo")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
|
b.Property<string>("Position")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("position");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectRole")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
|
b.Property<int>("RouteId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("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<string>("OptionName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("option_name");
|
||||||
|
|
||||||
|
b.Property<byte[]>("FileData")
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("file_data");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("OptionName")
|
||||||
|
.HasName("pk_settings");
|
||||||
|
|
||||||
|
b.ToTable("settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSlaSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ask_overdue_reason",
|
||||||
|
table: "template_route_steps",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "sla_hours",
|
||||||
|
table: "template_route_steps",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ask_overdue_reason",
|
||||||
|
table: "request_types",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "sla_hours",
|
||||||
|
table: "request_types",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
option_name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
file_data = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||||
|
value = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_settings", x => x.option_name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -31,6 +31,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
b.Property<string>("ListId")
|
b.Property<string>("ListId")
|
||||||
.HasMaxLength(512)
|
.HasMaxLength(512)
|
||||||
.HasColumnType("character varying(512)")
|
.HasColumnType("character varying(512)")
|
||||||
@@ -59,6 +63,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("character varying(512)")
|
.HasColumnType("character varying(512)")
|
||||||
.HasColumnName("site_url");
|
.HasColumnName("site_url");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_request_types");
|
.HasName("pk_request_types");
|
||||||
|
|
||||||
@@ -117,6 +125,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
|
b.Property<bool?>("AskOverdueReason")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("ask_overdue_reason");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
@@ -155,6 +167,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("route_id");
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
|
b.Property<int?>("SlaHours")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sla_hours");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
@@ -175,6 +191,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("template_route_steps", (string)null);
|
b.ToTable("template_route_steps", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("OptionName")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("option_name");
|
||||||
|
|
||||||
|
b.Property<byte[]>("FileData")
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("file_data");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("OptionName")
|
||||||
|
.HasName("pk_settings");
|
||||||
|
|
||||||
|
b.ToTable("settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
@@ -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<X509Certificate2> GetCertificateAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сертификат для входа в SharePoint — из таблицы settings (option_name задаётся
|
||||||
|
/// в SharePointOptions.CertificateSettingName, по умолчанию 'AzurePfx').
|
||||||
|
///
|
||||||
|
/// Синглтон с кешем: разбор .pfx стоит заметно дороже запроса к базе, а сам
|
||||||
|
/// сертификат меняется раз в год. Блокировка — чтобы параллельные запросы не
|
||||||
|
/// разбирали его одновременно при холодном старте.
|
||||||
|
///
|
||||||
|
/// Собственная таблица, а не общая с api-tnsu-portal, — осознанный выбор: базы
|
||||||
|
/// у сервисов разные и связывать их не хотим. Цена — копию сертификата придётся
|
||||||
|
/// обновлять в двух местах.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DbCertificateProvider(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<SharePointOptions> options) : ICertificateProvider
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
private X509Certificate2? _cached;
|
||||||
|
|
||||||
|
public async Task<X509Certificate2> 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<ApprovalRouteDbContext>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
||||||
|
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
||||||
|
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
||||||
|
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||||
|
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доступ к SharePoint REST от имени приложения (сертификат из таблицы settings).
|
||||||
|
///
|
||||||
|
/// Сырой HttpClient, а не PnP.Core/CSOM — по образцу api-tnsu-portal и потому,
|
||||||
|
/// что нужно всего одно действие: прочитать элементы двух списков. Тянуть ради
|
||||||
|
/// этого клиентскую библиотеку со своей объектной моделью незачем.
|
||||||
|
///
|
||||||
|
/// Клиент намеренно «глупый»: отдаёт сырой JSON и ничего не знает ни про SLA, ни
|
||||||
|
/// про строки маршрута — вся логика напоминаний живёт в Application.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SharePointClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ICertificateProvider certificateProvider,
|
||||||
|
IOptions<SharePointOptions> options) : ISharePointClient
|
||||||
|
{
|
||||||
|
// Токен запрашивается на КОНКРЕТНЫЙ хост SharePoint, поэтому и кеш по хосту:
|
||||||
|
// списки разных типов заявок могут жить на разных сайтах.
|
||||||
|
private readonly ConcurrentDictionary<string, AccessToken> _tokens = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private ClientCertificateCredential? _credential;
|
||||||
|
private readonly SemaphoreSlim _credentialLock = new(1, 1);
|
||||||
|
|
||||||
|
public async Task<string?> 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<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||||
|
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
||||||
|
GetItemsAsync(
|
||||||
|
siteUrl,
|
||||||
|
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items?{odataQuery}",
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<JsonElement>> 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<IReadOnlyList<JsonElement>> 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<JsonElement> 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<string> 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<ClientCertificateCredential> 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("'", "''");
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
|
public sealed class SharePointOptions
|
||||||
|
{
|
||||||
|
/// <summary>Тенант Entra — тот же, что у остальной авторизации.</summary>
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Приложение, от имени которого идём в SharePoint. Ему нужны разрешения
|
||||||
|
/// уровня приложения на чтение списков, выданные администратором тенанта.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Ключ в таблице settings, под которым лежит .pfx (option_name).</summary>
|
||||||
|
public string CertificateSettingName { get; set; } = "AzurePfx";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Пароль от .pfx. Намеренно берётся из конфигурации, а не зашивается в код:
|
||||||
|
/// в api-tnsu-portal он захардкожен в PostgresCertificateProvider, и повторять
|
||||||
|
/// это здесь не стоит — пароль от сертификата в исходниках не место.
|
||||||
|
/// </summary>
|
||||||
|
public string CertificatePassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
+2
@@ -3,6 +3,8 @@
|
|||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
<PackageReference Include="EFCore.NamingConventions" />
|
<PackageReference Include="EFCore.NamingConventions" />
|
||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
@@ -3,15 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
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
|
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
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastructure", "src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj", "{46EF4247-8B98-4CC9-A981-186C8D24163C}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -92,10 +90,6 @@ Global
|
|||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
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}
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"ExpandedNodes": [
|
|
||||||
"",
|
|
||||||
"\\Tnsu.ApprovalRoute.Api",
|
|
||||||
"\\Tnsu.ApprovalRoute.Application"
|
|
||||||
],
|
|
||||||
"SelectedNode": "\\Tnsu.ApprovalRoute.Application",
|
|
||||||
"PreviewInSolutionExplorer": false
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
|
||||||
|
|
||||||
public static class AuthSchemes
|
|
||||||
{
|
|
||||||
public const string Dev = "Dev";
|
|
||||||
}
|
|
||||||
@@ -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<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddAuthorization();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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);
|
|
||||||
@@ -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<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
+293
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
|
||||||
|
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
|
||||||
|
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
|
||||||
|
///
|
||||||
|
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||||
|
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||||
|
private const string DataListTitle = "Заявка тест — данные";
|
||||||
|
private const string ApprovalListTitle = "Заявка тест — согласование";
|
||||||
|
|
||||||
|
private static readonly DateTimeOffset Now = new(2026, 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<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// База у тестов класса общая, а сервис обходит ВСЕ типы с настроенным SLA —
|
||||||
|
// поэтому у каждого теста свой список: типы соседних тестов на мок не
|
||||||
|
// настроены, заголовок списка для них не найдётся, и в выборку они не попадут.
|
||||||
|
private async Task<string> 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<object> allRows, DateTimeOffset requestCreated)
|
||||||
|
{
|
||||||
|
var sharePoint = new Mock<ISharePointClient>();
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||||
|
.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<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(
|
||||||
|
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(allRows.Select(Json).ToList());
|
||||||
|
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
|
||||||
|
|
||||||
|
return new ReminderService(
|
||||||
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
|
sharePoint.Object,
|
||||||
|
Options.Create(new RemindersOptions()),
|
||||||
|
NullLogger<ReminderService>.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -181,6 +181,80 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SLA приезжает в резолв уже с применённым наследованием — это осознанное
|
||||||
|
// решение: строку списка согласования пишет request-portal, и он не должен
|
||||||
|
// повторять у себя правило «шаг перекрывает тип».
|
||||||
|
private async Task<int> 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<ResolvedRouteStepDto> 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<TemplateRouteStepService>();
|
||||||
|
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")]
|
[Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")]
|
||||||
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
|
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
<ProjectReference Include="..\..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: src/Tnsu.ApprovalRoute.Api/Dockerfile
|
dockerfile: Tnsu.ApprovalRoute.Api/Dockerfile
|
||||||
container_name: tnsu-approval-route-api
|
container_name: tnsu-approval-route-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+63
-8
@@ -3,30 +3,59 @@ import { Employee } from '../../models';
|
|||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
|
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 {
|
export interface IManageRequestsApproverModalProps {
|
||||||
title: string;
|
title: string;
|
||||||
initialEmail?: 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[];
|
employees: Employee[];
|
||||||
employeesLoading: boolean;
|
employeesLoading: boolean;
|
||||||
/** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */
|
/** Email'ы (в любом регистре), уже занятые другими шагами той же фазы — этот человек не может встать в фазу второй раз. */
|
||||||
excludedEmails: string[];
|
excludedEmails: string[];
|
||||||
onSave: (email: string, position: string) => Promise<void>;
|
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<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переназначение согласующего/исполнителя существующего шага (см.
|
* Правка существующего шага: кто согласует и какой у шага SLA.
|
||||||
* ManageRequestsScreen). Должность НЕ вводится — берётся из справочника
|
*
|
||||||
* сотрудников (api.tnsu.kz/Employee/All, см. Employee.position в
|
* Одна форма на оба вида правки намеренно — раньше SLA жил в отдельной модалке
|
||||||
* models/dictionaries.ts) на момент сохранения, отдельного поля для неё
|
* со своей кнопкой-флажком в строке, и получалось два похожих диалога и лишняя
|
||||||
* в этой форме нет. Добавление НОВОГО шага через модалку не идёт — см.
|
* иконка в и без того плотном ряду действий. Правки применяются одним
|
||||||
|
* сохранением (см. saveModal в ManageRequestsScreen).
|
||||||
|
*
|
||||||
|
* Должность НЕ вводится — берётся из справочника сотрудников
|
||||||
|
* (api.tnsu.kz/Employee/All, см. Employee.position в models/dictionaries.ts) на
|
||||||
|
* момент сохранения. Добавление НОВОГО шага через модалку не идёт — см.
|
||||||
* плашку-пикер под таблицей в ManageRequestsPhaseTable.
|
* плашку-пикер под таблицей в ManageRequestsPhaseTable.
|
||||||
*/
|
*/
|
||||||
export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalProps> = ({
|
export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalProps> = ({
|
||||||
title,
|
title,
|
||||||
initialEmail = '',
|
initialEmail = '',
|
||||||
|
initialSlaHours,
|
||||||
|
initialAskOverdueReason,
|
||||||
employees,
|
employees,
|
||||||
employeesLoading,
|
employeesLoading,
|
||||||
excludedEmails,
|
excludedEmails,
|
||||||
@@ -34,6 +63,10 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const [email, setEmail] = React.useState(initialEmail);
|
const [email, setEmail] = React.useState(initialEmail);
|
||||||
|
const [slaHours, setSlaHours] = React.useState(() => slaHoursToInput(initialSlaHours));
|
||||||
|
const [askOverdueReason, setAskOverdueReason] = React.useState<TriState>(
|
||||||
|
() => boolToTriState(initialAskOverdueReason)
|
||||||
|
);
|
||||||
const submit = useAsyncSubmit();
|
const submit = useAsyncSubmit();
|
||||||
|
|
||||||
// Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат
|
// Тот, кто уже занят в этой фазе, исключён из списка выбора — дубликат
|
||||||
@@ -51,8 +84,14 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
submit.fail(texts.manageRequests.validationDuplicateApprover);
|
submit.fail(texts.manageRequests.validationDuplicateApprover);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isValidSlaHours(slaHours)) {
|
||||||
|
submit.fail(texts.manageRequests.validationSlaHours);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
|
const position = findEmployeeByEmail(employees, trimmedEmail)?.position || '';
|
||||||
submit.run(() => onSave(trimmedEmail, position));
|
submit.run(() =>
|
||||||
|
onSave(trimmedEmail, position, parseSlaHours(slaHours), triStateToBool(askOverdueReason))
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +108,22 @@ export const ManageRequestsApproverModal: React.FC<IManageRequestsApproverModalP
|
|||||||
onChange={setEmail}
|
onChange={setEmail}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={texts.manageRequests.slaHoursLabel} hint={texts.manageRequests.slaHoursHint}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={slaHours}
|
||||||
|
onChange={e => setSlaHours(e.target.value)}
|
||||||
|
placeholder={texts.manageRequests.slaHoursPlaceholder}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.manageRequests.askOverdueReasonLabel} hint={texts.manageRequests.askOverdueReasonHint}>
|
||||||
|
<Select
|
||||||
|
options={TRI_STATE_OPTIONS}
|
||||||
|
value={askOverdueReason}
|
||||||
|
onChange={e => setAskOverdueReason(e.target.value as TriState)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,23 @@ import { texts } from '../../texts';
|
|||||||
import { toEmployeeOption } from '../../utils/employee';
|
import { toEmployeeOption } from '../../utils/employee';
|
||||||
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
|
||||||
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
|
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
|
||||||
|
import { effectiveSlaHours, formatSlaHours, isRowOverdue } from '../../utils/sla';
|
||||||
|
|
||||||
export interface IManageRequestsPhaseTableProps {
|
export interface IManageRequestsPhaseTableProps {
|
||||||
title: string;
|
title: string;
|
||||||
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
|
/** Строки ОДНОЙ фазы, уже отсортированные по order (см. ManageRequestsScreen). */
|
||||||
rows: ManagedApprovalRow[];
|
rows: ManagedApprovalRow[];
|
||||||
|
/**
|
||||||
|
* ВСЕ строки заявки (обе фазы) — нужны для расчёта момента активации шага
|
||||||
|
* (см. utils/sla.ts/activatedAt): первый шаг исполнения отсчитывается от
|
||||||
|
* последнего решения фазы согласования, а той фазы в `rows` этой таблицы нет.
|
||||||
|
*/
|
||||||
|
allRows: ManagedApprovalRow[];
|
||||||
|
/** Дата создания заявки (ISO) — точка отсчёта для самого первого шага согласования. */
|
||||||
|
requestCreatedIso: string;
|
||||||
|
/** SLA типа заявки в часах — то, что действует, если на строке своё значение не задано. */
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
|
||||||
|
requestTypeSlaHours: number | null;
|
||||||
/** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */
|
/** Заявка целиком только для просмотра (закрыта) — контролов нет вообще. */
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +87,9 @@ function diffOrders(original: ManagedApprovalRow[], renumbered: Row[]): Array<{
|
|||||||
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps> = ({
|
||||||
title,
|
title,
|
||||||
rows,
|
rows,
|
||||||
|
allRows,
|
||||||
|
requestCreatedIso,
|
||||||
|
requestTypeSlaHours,
|
||||||
readOnly,
|
readOnly,
|
||||||
requestRejected,
|
requestRejected,
|
||||||
statusVariant,
|
statusVariant,
|
||||||
@@ -187,6 +202,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
<th>{texts.manageRequests.columnPosition}</th>
|
<th>{texts.manageRequests.columnPosition}</th>
|
||||||
<th>{texts.manageRequests.columnStatus}</th>
|
<th>{texts.manageRequests.columnStatus}</th>
|
||||||
<th>{texts.manageRequests.columnDate}</th>
|
<th>{texts.manageRequests.columnDate}</th>
|
||||||
|
<th>{texts.manageRequests.columnSla}</th>
|
||||||
{!readOnly && <th />}
|
{!readOnly && <th />}
|
||||||
{!readOnly && <th />}
|
{!readOnly && <th />}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -244,6 +260,22 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
|
|||||||
<td>{r.position || texts.common.empty}</td>
|
<td>{r.position || texts.common.empty}</td>
|
||||||
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
|
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
|
||||||
<td>{r.date || texts.common.empty}</td>
|
<td>{r.date || texts.common.empty}</td>
|
||||||
|
{(() => {
|
||||||
|
const hours = effectiveSlaHours(r, requestTypeSlaHours);
|
||||||
|
// Просроченным может быть только шаг, у которого прямо сейчас
|
||||||
|
// идёт очередь — решённый уже не тикает, ждущий ещё не начался.
|
||||||
|
const overdue =
|
||||||
|
(r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution) &&
|
||||||
|
isRowOverdue(r, allRows, requestCreatedIso, requestTypeSlaHours, new Date());
|
||||||
|
return (
|
||||||
|
<td className={overdue ? styles.slaOverdue : undefined}>
|
||||||
|
{formatSlaHours(hours)}
|
||||||
|
{r.slaHours === null && hours !== null && (
|
||||||
|
<CellNote>{texts.manageRequests.slaInherited}</CellNote>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
|
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
|
||||||
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
|
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
|
||||||
шаг целиком, а не отдельного участника параллельной группы. */}
|
шаг целиком, а не отдельного участника параллельной группы. */}
|
||||||
|
|||||||
+8
@@ -97,3 +97,11 @@
|
|||||||
.addRow {
|
.addRow {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ячейка SLA просроченного АКТИВНОГО шага (см. isRowOverdue в utils/sla.ts) —
|
||||||
|
// только текст, без фона: фон был бы третьим одновременным акцентом в строке
|
||||||
|
// (жёлтая подсветка hover уже есть) и мешал бы, а не помогал заметить.
|
||||||
|
.slaOverdue {
|
||||||
|
color: $red-500;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,21 +366,40 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
// загруженного справочника сотрудников (тот же источник, что подставляет
|
// загруженного справочника сотрудников (тот же источник, что подставляет
|
||||||
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
|
// должность, — см. ManageRequestsApproverModal), чтобы не делать лишний
|
||||||
// запрос только за отображаемым именем.
|
// запрос только за отображаемым именем.
|
||||||
const saveModal = (email: string, position: string): Promise<void> => {
|
const saveModal = (
|
||||||
|
email: string,
|
||||||
|
position: string,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», работает SLA типа заявки
|
||||||
|
slaHours: number | null,
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. выше
|
||||||
|
askOverdueReason: boolean | null
|
||||||
|
): Promise<void> => {
|
||||||
if (!matrix || !modal) return Promise.resolve();
|
if (!matrix || !modal) return Promise.resolve();
|
||||||
setSaveError(undefined);
|
setSaveError(undefined);
|
||||||
const rowId = modal.id;
|
const rowId = modal.id;
|
||||||
|
// Два запроса последовательно, а не параллельно: оба правят ОДНУ строку
|
||||||
|
// SharePoint, и параллельные update по одному элементу списка могут
|
||||||
|
// затереть друг друга (последний выигрывает).
|
||||||
return requestManage
|
return requestManage
|
||||||
.reassignApprover(matrix, rowId, email, position)
|
.reassignApprover(matrix, rowId, email, position)
|
||||||
|
.then(() => requestManage.updateRowSla(matrix, rowId, slaHours, askOverdueReason))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
const found = findEmployeeByEmail(employees.items, email);
|
const found = findEmployeeByEmail(employees.items, email);
|
||||||
setMatrix(prev =>
|
setMatrix(prev =>
|
||||||
prev ? patchRow(prev, rowId, { approverEmail: email, approverName: found?.fio || email, position }) : prev
|
prev
|
||||||
|
? patchRow(prev, rowId, {
|
||||||
|
approverEmail: email,
|
||||||
|
approverName: found?.fio || email,
|
||||||
|
position,
|
||||||
|
slaHours,
|
||||||
|
askOverdueReason
|
||||||
|
})
|
||||||
|
: prev
|
||||||
);
|
);
|
||||||
closeModal();
|
closeModal();
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
setSaveError(reportError(e, 'saveModal: reassignApprover'));
|
setSaveError(reportError(e, 'saveModal: reassignApprover/updateRowSla'));
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -421,10 +440,15 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
position,
|
position,
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
date: '',
|
date: '',
|
||||||
|
dateIso: '',
|
||||||
comment: '',
|
comment: '',
|
||||||
order,
|
order,
|
||||||
allowReassign: false,
|
allowReassign: false,
|
||||||
phase
|
phase,
|
||||||
|
// Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не
|
||||||
|
// имеет — работает SLA типа заявки, пока его не зададут отдельно.
|
||||||
|
slaHours: null,
|
||||||
|
askOverdueReason: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const previous = matrix;
|
const previous = matrix;
|
||||||
@@ -544,6 +568,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
<ManageRequestsPhaseTable
|
<ManageRequestsPhaseTable
|
||||||
title={texts.manageRequests.approvalPhaseTitle}
|
title={texts.manageRequests.approvalPhaseTitle}
|
||||||
rows={approvalRows}
|
rows={approvalRows}
|
||||||
|
allRows={matrix ? matrix.rows : []}
|
||||||
|
requestCreatedIso={matrix ? matrix.request.created : ''}
|
||||||
|
requestTypeSlaHours={requestType?.slaHours ?? null}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
requestRejected={requestRejected}
|
requestRejected={requestRejected}
|
||||||
statusVariant={statusVariant}
|
statusVariant={statusVariant}
|
||||||
@@ -560,6 +587,9 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
<ManageRequestsPhaseTable
|
<ManageRequestsPhaseTable
|
||||||
title={texts.manageRequests.executionPhaseTitle}
|
title={texts.manageRequests.executionPhaseTitle}
|
||||||
rows={executionRows}
|
rows={executionRows}
|
||||||
|
allRows={matrix ? matrix.rows : []}
|
||||||
|
requestCreatedIso={matrix ? matrix.request.created : ''}
|
||||||
|
requestTypeSlaHours={requestType?.slaHours ?? null}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
requestRejected={requestRejected}
|
requestRejected={requestRejected}
|
||||||
statusVariant={statusVariant}
|
statusVariant={statusVariant}
|
||||||
@@ -582,6 +612,8 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
|
|||||||
<ManageRequestsApproverModal
|
<ManageRequestsApproverModal
|
||||||
title={texts.manageRequests.reassignTitle}
|
title={texts.manageRequests.reassignTitle}
|
||||||
initialEmail={modal.approverEmail}
|
initialEmail={modal.approverEmail}
|
||||||
|
initialSlaHours={modal.slaHours}
|
||||||
|
initialAskOverdueReason={modal.askOverdueReason}
|
||||||
employees={employees.items}
|
employees={employees.items}
|
||||||
employeesLoading={employees.isLoading}
|
employeesLoading={employees.isLoading}
|
||||||
excludedEmails={reassignExcludedEmails}
|
excludedEmails={reassignExcludedEmails}
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { ApprovalRouteApi } from '../../services/api';
|
|||||||
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
|
||||||
import { RequestType } from '../../models';
|
import { RequestType } from '../../models';
|
||||||
import { texts } from '../../texts';
|
import { texts } from '../../texts';
|
||||||
|
import {
|
||||||
|
boolToTriState,
|
||||||
|
formatSlaHours,
|
||||||
|
isValidSlaHours,
|
||||||
|
parseSlaHours,
|
||||||
|
slaHoursToInput,
|
||||||
|
triStateToBool,
|
||||||
|
TriState,
|
||||||
|
TRI_STATE_OPTIONS
|
||||||
|
} from '../../utils/sla';
|
||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
import { useSharePointLists } from '../../hooks/useSharePointLists';
|
||||||
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
|
||||||
@@ -38,9 +48,14 @@ interface IFormState {
|
|||||||
section: string;
|
section: string;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
listId: string;
|
listId: string;
|
||||||
|
/** Строкой, а не числом: пустое поле — «SLA не настроен», и это не то же самое, что 0. */
|
||||||
|
slaHours: string;
|
||||||
|
askOverdueReason: TriState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listId: '' };
|
const EMPTY_FORM: IFormState = {
|
||||||
|
name: '', pageUrl: '', section: '', siteUrl: '', listId: '', slaHours: '', askOverdueReason: ''
|
||||||
|
};
|
||||||
|
|
||||||
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
// siteUrl/listId — поля RequestType, зеркалящие nullable JSON-контракт бэкенда
|
||||||
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
|
||||||
@@ -88,7 +103,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
pageUrl: item.pageUrl,
|
pageUrl: item.pageUrl,
|
||||||
section: item.section,
|
section: item.section,
|
||||||
siteUrl: item.siteUrl ?? '',
|
siteUrl: item.siteUrl ?? '',
|
||||||
listId: item.listId ?? ''
|
listId: item.listId ?? '',
|
||||||
|
slaHours: slaHoursToInput(item.slaHours),
|
||||||
|
askOverdueReason: boolToTriState(item.askOverdueReason)
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -104,6 +121,10 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
submit.fail(texts.requestTypes.validationListReference);
|
submit.fail(texts.requestTypes.validationListReference);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isValidSlaHours(form.slaHours)) {
|
||||||
|
submit.fail(texts.requestTypes.validationSlaHours);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const input = {
|
const input = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -114,7 +135,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
// eslint-disable-next-line @rushstack/no-new-null
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
siteUrl: siteUrl || null,
|
siteUrl: siteUrl || null,
|
||||||
// eslint-disable-next-line @rushstack/no-new-null
|
// eslint-disable-next-line @rushstack/no-new-null
|
||||||
listId: listId || null
|
listId: listId || null,
|
||||||
|
slaHours: parseSlaHours(form.slaHours),
|
||||||
|
askOverdueReason: triStateToBool(form.askOverdueReason)
|
||||||
};
|
};
|
||||||
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
|
||||||
|
|
||||||
@@ -147,6 +170,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
|
||||||
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
|
||||||
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
|
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
|
||||||
|
{ key: 'sla', header: texts.requestTypes.columnSla, render: r => formatSlaHours(r.slaHours) },
|
||||||
{ key: 'listId', header: texts.requestTypes.columnListId, render: r => <ListIdCell client={sharePoint} siteUrl={r.siteUrl} listId={r.listId} /> },
|
{ key: 'listId', header: texts.requestTypes.columnListId, render: r => <ListIdCell client={sharePoint} siteUrl={r.siteUrl} listId={r.listId} /> },
|
||||||
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
{ key: 'siteUrl', header: texts.requestTypes.columnSiteUrl, render: r => <SiteUrlCell client={sharePoint} siteUrl={r.siteUrl} /> },
|
||||||
{
|
{
|
||||||
@@ -238,6 +262,22 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
|
|||||||
onChange={e => setForm({ ...form, listId: e.target.value })}
|
onChange={e => setForm({ ...form, listId: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={texts.requestTypes.slaHoursLabel} hint={texts.requestTypes.slaHoursHint}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.slaHours}
|
||||||
|
onChange={e => setForm({ ...form, slaHours: e.target.value })}
|
||||||
|
placeholder={texts.requestTypes.slaHoursPlaceholder}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.requestTypes.askOverdueReasonLabel} hint={texts.requestTypes.askOverdueReasonHint}>
|
||||||
|
<Select
|
||||||
|
options={TRI_STATE_OPTIONS}
|
||||||
|
value={form.askOverdueReason}
|
||||||
|
onChange={e => setForm({ ...form, askOverdueReason: e.target.value as TriState })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ import { useSharePointFields } from '../../hooks/useSharePointFields';
|
|||||||
import { ApprovalRouteApi } from '../../services/api';
|
import { ApprovalRouteApi } from '../../services/api';
|
||||||
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
|
||||||
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
|
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 {
|
export interface IRouteStepFormModalProps {
|
||||||
api: ApprovalRouteApi;
|
api: ApprovalRouteApi;
|
||||||
@@ -47,6 +56,9 @@ interface IFormState {
|
|||||||
fieldName: string;
|
fieldName: string;
|
||||||
operation: ComparisonOperator;
|
operation: ComparisonOperator;
|
||||||
value: string;
|
value: string;
|
||||||
|
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
|
||||||
|
slaHours: string;
|
||||||
|
askOverdueReason: TriState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
|
||||||
@@ -78,7 +90,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
|||||||
hasCondition: false,
|
hasCondition: false,
|
||||||
fieldName: '',
|
fieldName: '',
|
||||||
operation: ComparisonOperator.GreaterThan,
|
operation: ComparisonOperator.GreaterThan,
|
||||||
value: ''
|
value: '',
|
||||||
|
slaHours: '',
|
||||||
|
askOverdueReason: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -93,7 +107,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
|
|||||||
hasCondition: Boolean(editing.fieldName),
|
hasCondition: Boolean(editing.fieldName),
|
||||||
fieldName: editing.fieldName ?? '',
|
fieldName: editing.fieldName ?? '',
|
||||||
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
|
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<IRouteStepFormModalProps> = ({
|
|||||||
submit.fail(texts.routeSteps.validationConditionNumber);
|
submit.fail(texts.routeSteps.validationConditionNumber);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isValidSlaHours(form.slaHours)) {
|
||||||
|
submit.fail(texts.routeSteps.validationSlaHours);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
orderNo,
|
orderNo,
|
||||||
@@ -175,7 +195,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
headType: form.kind === 'headType' ? form.headType : null,
|
headType: form.kind === 'headType' ? form.headType : null,
|
||||||
fieldName: form.hasCondition ? form.fieldName.trim() : null,
|
fieldName: form.hasCondition ? form.fieldName.trim() : null,
|
||||||
operation: form.hasCondition ? form.operation : 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
|
const request = editing
|
||||||
@@ -283,6 +305,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label={texts.routeSteps.slaHoursLabel} hint={texts.routeSteps.slaHoursHint}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.slaHours}
|
||||||
|
onChange={e => setForm({ ...form, slaHours: e.target.value })}
|
||||||
|
placeholder={texts.routeSteps.slaHoursPlaceholder}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={texts.routeSteps.askOverdueReasonLabel} hint={texts.routeSteps.askOverdueReasonHint}>
|
||||||
|
<Select
|
||||||
|
options={TRI_STATE_OPTIONS}
|
||||||
|
value={form.askOverdueReason}
|
||||||
|
onChange={e => setForm({ ...form, askOverdueReason: e.target.value as TriState })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListId ? texts.routeSteps.conditionDisabledHint : undefined}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={texts.routeSteps.conditionCheckbox}
|
label={texts.routeSteps.conditionCheckbox}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export function AsyncSearchableSelect<T>({
|
|||||||
// Растущий счётчик запросов: если ответ пришёл не от последнего запроса
|
// Растущий счётчик запросов: если ответ пришёл не от последнего запроса
|
||||||
// (например, пользователь допечатал ещё символ) — просто игнорируем его.
|
// (например, пользователь допечатал ещё символ) — просто игнорируем его.
|
||||||
const requestIdRef = React.useRef(0);
|
const requestIdRef = React.useRef(0);
|
||||||
|
// getValue нужен внутри runSearch (дедуп при доклейке страницы), но в deps его
|
||||||
|
// класть нельзя: пропс приходит инлайновой стрелкой и меняет ссылку на каждом
|
||||||
|
// рендере — runSearch пересоздавался бы постоянно и перезапускал эффект
|
||||||
|
// бесконечной прокрутки. Держим последнюю версию в ref.
|
||||||
|
const getValueRef = React.useRef(getValue);
|
||||||
|
getValueRef.current = getValue;
|
||||||
|
|
||||||
const runSearch = React.useCallback((q: string, targetPage: number, append: boolean) => {
|
const runSearch = React.useCallback((q: string, targetPage: number, append: boolean) => {
|
||||||
const requestId = ++requestIdRef.current;
|
const requestId = ++requestIdRef.current;
|
||||||
@@ -71,7 +77,16 @@ export function AsyncSearchableSelect<T>({
|
|||||||
search(q, targetPage)
|
search(q, targetPage)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (requestIdRef.current !== requestId) return; // устарел
|
if (requestIdRef.current !== requestId) return; // устарел
|
||||||
setItems(prev => (append ? [...prev, ...result.items] : result.items));
|
// Дедуп при доклейке: соседние страницы могут пересекаться, если индекс
|
||||||
|
// переупорядочился между запросами (StartRow смещает окно по РАНГУ, а не
|
||||||
|
// по устойчивому курсору). Дубликат здесь = два пункта с одинаковым
|
||||||
|
// React-ключом, то есть заявка в списке дважды. Внутри одной страницы
|
||||||
|
// дубли отсекает уже источник (см. searchRequests в requestManageService).
|
||||||
|
setItems(prev => {
|
||||||
|
if (!append) return result.items;
|
||||||
|
const shown = new Set(prev.map(i => getValueRef.current(i)));
|
||||||
|
return [...prev, ...result.items.filter(i => !shown.has(getValueRef.current(i)))];
|
||||||
|
});
|
||||||
setHasMore(result.hasMore);
|
setHasMore(result.hasMore);
|
||||||
setPage(targetPage);
|
setPage(targetPage);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -97,10 +97,28 @@ export interface ManagedApprovalRow {
|
|||||||
position: string;
|
position: string;
|
||||||
status: string;
|
status: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
/**
|
||||||
|
* Та же дата, что в `date`, но необработанная ISO-строка (или '', если решения
|
||||||
|
* ещё не было) — нужна для расчёта просрочки (см. utils/sla.ts/isRowOverdue):
|
||||||
|
* `date` отформатирована в «дд.мм.гггг» и назад в Date уже не превращается
|
||||||
|
* надёжно (зависит от локали при парсинге).
|
||||||
|
*/
|
||||||
|
dateIso: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
order: number;
|
order: number;
|
||||||
allowReassign: boolean;
|
allowReassign: boolean;
|
||||||
phase: string;
|
phase: string;
|
||||||
|
/**
|
||||||
|
* SLA именно ЭТОЙ строки в часах. Значение проставляется при создании
|
||||||
|
* маршрута (портал берёт его из resolve, где наследование «шаг перекрывает
|
||||||
|
* тип» уже применено), и здесь его можно поправить для конкретной заявки.
|
||||||
|
* null — в строке не задано, работает SLA типа заявки.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- пусто в SharePoint приходит как null и значит «не задано», это не 0
|
||||||
|
slaHours: number | null;
|
||||||
|
/** Спрашивать ли причину просрочки по этой строке. null — берётся от типа заявки. */
|
||||||
|
// eslint-disable-next-line @rushstack/no-new-null -- см. slaHours
|
||||||
|
askOverdueReason: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManagedRequestMatrix {
|
export interface ManagedRequestMatrix {
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export interface RequestType {
|
|||||||
/** Сайт SharePoint, где живёт listId. Заполнен тогда и только тогда, когда заполнен listId. */
|
/** Сайт SharePoint, где живёт listId. Заполнен тогда и только тогда, когда заполнен listId. */
|
||||||
siteUrl: string | null;
|
siteUrl: string | null;
|
||||||
section: string;
|
section: string;
|
||||||
|
/**
|
||||||
|
* SLA по умолчанию для всех шагов заявок этого типа, в часах.
|
||||||
|
* null — SLA не настроен, напоминания по типу не строятся.
|
||||||
|
* Отличать null от 0 обязательно: 0 значит «напоминать сразу».
|
||||||
|
*/
|
||||||
|
slaHours: number | null;
|
||||||
|
/** Спрашивать ли причину просрочки — значение по умолчанию для шагов. null — не задано. */
|
||||||
|
askOverdueReason: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRequestTypeInput {
|
export interface CreateRequestTypeInput {
|
||||||
@@ -18,6 +26,8 @@ export interface CreateRequestTypeInput {
|
|||||||
listId: string | null;
|
listId: string | null;
|
||||||
siteUrl: string | null;
|
siteUrl: string | null;
|
||||||
section: string;
|
section: string;
|
||||||
|
slaHours: number | null;
|
||||||
|
askOverdueReason: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
export type UpdateRequestTypeInput = CreateRequestTypeInput;
|
||||||
@@ -28,4 +38,6 @@ export interface PatchRequestTypeInput {
|
|||||||
listId?: string | null;
|
listId?: string | null;
|
||||||
siteUrl?: string | null;
|
siteUrl?: string | null;
|
||||||
section?: string;
|
section?: string;
|
||||||
|
slaHours?: number | null;
|
||||||
|
askOverdueReason?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export interface TemplateRouteStep {
|
|||||||
operation: ComparisonOperator | null;
|
operation: ComparisonOperator | null;
|
||||||
/** Значение для сравнения с fieldName. */
|
/** Значение для сравнения с fieldName. */
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
/**
|
||||||
|
* SLA шага в часах — перекрывает SLA типа заявки.
|
||||||
|
* null — «не задано, наследовать»; 0 — «напоминать сразу».
|
||||||
|
*/
|
||||||
|
slaHours: number | null;
|
||||||
|
/** Спрашивать ли причину просрочки на этом шаге. null — наследовать от типа. */
|
||||||
|
askOverdueReason: boolean | null;
|
||||||
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
|
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
|
||||||
warning?: string | null;
|
warning?: string | null;
|
||||||
}
|
}
|
||||||
@@ -34,6 +41,8 @@ export interface CreateTemplateRouteStepInput {
|
|||||||
fieldName: string | null;
|
fieldName: string | null;
|
||||||
operation: ComparisonOperator | null;
|
operation: ComparisonOperator | null;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
|
slaHours: number | null;
|
||||||
|
askOverdueReason: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
|
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
|
||||||
@@ -49,6 +58,8 @@ export interface PatchTemplateRouteStepInput {
|
|||||||
fieldName?: string | null;
|
fieldName?: string | null;
|
||||||
operation?: ComparisonOperator | null;
|
operation?: ComparisonOperator | null;
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
|
slaHours?: number | null;
|
||||||
|
askOverdueReason?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
|
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '../models';
|
} from '../models';
|
||||||
|
|
||||||
// Тонкий 1:1 фасад над контроллерами бэкенда:
|
// Тонкий 1:1 фасад над контроллерами бэкенда:
|
||||||
// backend/src/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
|
// backend/Tnsu.ApprovalRoute.Api/Controllers/{RequestTypes,TemplateRoutes,TemplateRouteSteps}Controller.cs
|
||||||
|
|
||||||
export class ApprovalRouteApi {
|
export class ApprovalRouteApi {
|
||||||
constructor(private readonly http: IHttpClient) {}
|
constructor(private readonly http: IHttpClient) {}
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ const FIELDS = {
|
|||||||
comment: 'Comment',
|
comment: 'Comment',
|
||||||
order: 'StepOrder',
|
order: 'StepOrder',
|
||||||
allowReassign: 'AllowReassignToOtherApprovers',
|
allowReassign: 'AllowReassignToOtherApprovers',
|
||||||
phase: 'Phase'
|
phase: 'Phase',
|
||||||
|
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
|
||||||
|
slaHours: 'SLAhours',
|
||||||
|
askOverdueReason: 'AskOverdueReason'
|
||||||
};
|
};
|
||||||
|
|
||||||
function siteOrigin(siteUrl: string): string {
|
function siteOrigin(siteUrl: string): string {
|
||||||
@@ -152,7 +155,22 @@ export class RequestManageService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
|
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string }>;
|
||||||
const ids = rawRows.map(r => Number(r.ListItemID)).filter(id => !isNaN(id) && id > 0);
|
// Дедуп по Id обязателен: TrimDuplicates: false выше выключает штатную
|
||||||
|
// дедупликацию Search (она иногда схлопывает нужные нам заявки), и тогда
|
||||||
|
// индекс может отдать один и тот же элемент несколькими хитами
|
||||||
|
// (переиндексация, несколько «версий» в crawl). Без этого фильтра хиты
|
||||||
|
// превращались в пункты комбобокса один-в-один, и заявка показывалась в
|
||||||
|
// списке дважды — с одинаковым React-ключом. Та же причина и то же лечение,
|
||||||
|
// что в request-portal (features/myRequests/myRequests.service.ts/hitsToEntries).
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const ids = rawRows
|
||||||
|
.map(r => Number(r.ListItemID))
|
||||||
|
.filter(id => !isNaN(id) && id > 0)
|
||||||
|
.filter(id => {
|
||||||
|
if (seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
if (ids.length === 0) return { items: [], hasMore: false };
|
if (ids.length === 0) return { items: [], hasMore: false };
|
||||||
|
|
||||||
const list = sp.web.getList(requestType.listId);
|
const list = sp.web.getList(requestType.listId);
|
||||||
@@ -205,6 +223,8 @@ export class RequestManageService {
|
|||||||
FIELDS.order,
|
FIELDS.order,
|
||||||
FIELDS.allowReassign,
|
FIELDS.allowReassign,
|
||||||
FIELDS.phase,
|
FIELDS.phase,
|
||||||
|
FIELDS.slaHours,
|
||||||
|
FIELDS.askOverdueReason,
|
||||||
`${FIELDS.approver}/Id`,
|
`${FIELDS.approver}/Id`,
|
||||||
`${FIELDS.approver}/Title`,
|
`${FIELDS.approver}/Title`,
|
||||||
`${FIELDS.approver}/EMail`
|
`${FIELDS.approver}/EMail`
|
||||||
@@ -223,10 +243,15 @@ export class RequestManageService {
|
|||||||
position: (r.Title as string) || '',
|
position: (r.Title as string) || '',
|
||||||
status: (r[FIELDS.status] as string) || '',
|
status: (r[FIELDS.status] as string) || '',
|
||||||
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
date: formatDateIso((r[FIELDS.date] as string) || ''),
|
||||||
|
dateIso: (r[FIELDS.date] as string) || '',
|
||||||
comment: (r[FIELDS.comment] as string) || '',
|
comment: (r[FIELDS.comment] as string) || '',
|
||||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||||
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval
|
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
|
||||||
|
// ?? null, а не || null: ноль здесь — значимое значение («напоминать
|
||||||
|
// сразу»), и превращать его в «не задано» нельзя.
|
||||||
|
slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
|
||||||
|
askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,6 +281,28 @@ export class RequestManageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поправить SLA конкретной строки маршрута.
|
||||||
|
*
|
||||||
|
* null пишется в SharePoint как есть — это «в строке не задано», и тогда
|
||||||
|
* работает SLA типа заявки. Отдельный метод, а не флаг в reassignApprover:
|
||||||
|
* SLA правят независимо от того, кто на шаге, и в любом статусе строки.
|
||||||
|
*/
|
||||||
|
public async updateRowSla(
|
||||||
|
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||||
|
rowId: number,
|
||||||
|
// 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<void> {
|
||||||
|
const sp = this.clientFor(matrix.siteUrl);
|
||||||
|
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
|
||||||
|
[FIELDS.slaHours]: slaHours,
|
||||||
|
[FIELDS.askOverdueReason]: askOverdueReason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Удалить строку маршрута. */
|
/** Удалить строку маршрута. */
|
||||||
public async removeRow(
|
public async removeRow(
|
||||||
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export const texts = {
|
|||||||
loading: 'Загрузка...',
|
loading: 'Загрузка...',
|
||||||
yes: 'Да',
|
yes: 'Да',
|
||||||
no: 'Нет',
|
no: 'Нет',
|
||||||
|
/**
|
||||||
|
* Третье состояние для настроек с наследованием (SLA, опрос причины
|
||||||
|
* просрочки): пусто значит «взять значение уровнем выше», и это НЕ то же
|
||||||
|
* самое, что «Нет» или «0» — их выбирают осознанно.
|
||||||
|
*/
|
||||||
|
inherited: 'Не задано',
|
||||||
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
|
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
|
||||||
empty: '—'
|
empty: '—'
|
||||||
},
|
},
|
||||||
@@ -108,6 +114,7 @@ export const texts = {
|
|||||||
columnSection: 'Раздел',
|
columnSection: 'Раздел',
|
||||||
columnListId: 'Список SharePoint',
|
columnListId: 'Список SharePoint',
|
||||||
columnSiteUrl: 'Сайт SharePoint',
|
columnSiteUrl: 'Сайт SharePoint',
|
||||||
|
columnSla: 'SLA',
|
||||||
createTitle: 'Новый тип заявки',
|
createTitle: 'Новый тип заявки',
|
||||||
editTitle: 'Редактировать тип заявки',
|
editTitle: 'Редактировать тип заявки',
|
||||||
nameLabel: 'Название',
|
nameLabel: 'Название',
|
||||||
@@ -127,6 +134,14 @@ export const texts = {
|
|||||||
listIdLoading: 'Загрузка списков сайта...',
|
listIdLoading: 'Загрузка списков сайта...',
|
||||||
listIdEmpty: 'На этом сайте нет доступных списков',
|
listIdEmpty: 'На этом сайте нет доступных списков',
|
||||||
listIdError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
|
listIdError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
|
||||||
|
slaHoursLabel: 'SLA, часов',
|
||||||
|
slaHoursHint:
|
||||||
|
'Через сколько часов простоя шага напоминать. Пусто — напоминания по этому типу заявок не работают. ' +
|
||||||
|
'Отсчёт идёт от момента, когда шаг стал активным.',
|
||||||
|
slaHoursPlaceholder: 'Например, 24',
|
||||||
|
askOverdueReasonLabel: 'Спрашивать причину просрочки',
|
||||||
|
askOverdueReasonHint: 'Значение по умолчанию для всех шагов этого типа заявок.',
|
||||||
|
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
|
||||||
validationRequired: 'Заполните имя, Url страницы и раздел.',
|
validationRequired: 'Заполните имя, Url страницы и раздел.',
|
||||||
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
|
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
|
||||||
deleteTitle: 'Удалить тип заявки?',
|
deleteTitle: 'Удалить тип заявки?',
|
||||||
@@ -210,6 +225,12 @@ export const texts = {
|
|||||||
headTypeLabel: 'Тип руководителя',
|
headTypeLabel: 'Тип руководителя',
|
||||||
reassignLabel: 'Переназначение',
|
reassignLabel: 'Переназначение',
|
||||||
reassignCheckbox: 'Разрешить переназначать другим согласующим',
|
reassignCheckbox: 'Разрешить переназначать другим согласующим',
|
||||||
|
slaHoursLabel: 'SLA шага, часов',
|
||||||
|
slaHoursHint:
|
||||||
|
'Пусто — берётся SLA типа заявки. Заданное здесь значение его перекрывает; 0 означает «напоминать сразу».',
|
||||||
|
slaHoursPlaceholder: 'Как у типа заявки',
|
||||||
|
askOverdueReasonLabel: 'Спрашивать причину просрочки',
|
||||||
|
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
|
||||||
conditionLabel: 'Условие видимости шага',
|
conditionLabel: 'Условие видимости шага',
|
||||||
conditionCheckbox: 'Показывать шаг только при выполнении условия',
|
conditionCheckbox: 'Показывать шаг только при выполнении условия',
|
||||||
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
|
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
|
||||||
@@ -226,6 +247,7 @@ export const texts = {
|
|||||||
validationPosition: 'Укажите должность.',
|
validationPosition: 'Укажите должность.',
|
||||||
validationCondition: 'Заполните поле списка, оператор и значение условия.',
|
validationCondition: 'Заполните поле списка, оператор и значение условия.',
|
||||||
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
|
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
|
||||||
|
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
|
||||||
deleteTitle: 'Удалить шаг?',
|
deleteTitle: 'Удалить шаг?',
|
||||||
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
|
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
|
||||||
},
|
},
|
||||||
@@ -319,6 +341,15 @@ export const texts = {
|
|||||||
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
|
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
|
||||||
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
|
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
|
||||||
reassignTitle: 'Переназначить шаг',
|
reassignTitle: 'Переназначить шаг',
|
||||||
|
columnSla: 'SLA',
|
||||||
|
slaInherited: 'от типа заявки',
|
||||||
|
slaHoursLabel: 'SLA, часов',
|
||||||
|
slaHoursHint:
|
||||||
|
'Через сколько часов простоя напоминать по этому шагу. Пусто — берётся SLA типа заявки; 0 — напоминать сразу.',
|
||||||
|
slaHoursPlaceholder: 'Как у типа заявки',
|
||||||
|
askOverdueReasonLabel: 'Спрашивать причину просрочки',
|
||||||
|
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
|
||||||
|
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
|
||||||
approverLabel: 'Сотрудник',
|
approverLabel: 'Сотрудник',
|
||||||
approverPlaceholder: 'Выберите сотрудника',
|
approverPlaceholder: 'Выберите сотрудника',
|
||||||
validationApprover: 'Выберите сотрудника.',
|
validationApprover: 'Выберите сотрудника.',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user