Sla (#11)
Tests / backend-test (push) Successful in 1m50s
Tests / frontend-test (push) Successful in 49s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
2026-08-13 04:44:08 +00:00
parent 7f2aabbf29
commit d076f4e98d
103 changed files with 2606 additions and 1265 deletions
+1
View File
@@ -3,6 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Azure.Identity" Version="1.14.0" />
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
<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();
}
+21
View File
@@ -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"]
@@ -9,6 +9,7 @@ using Scalar.AspNetCore;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure;
@@ -35,6 +36,7 @@ builder.Services.AddCors(options =>
});
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
builder.Services.AddApprovalRouteAuth(builder.Configuration);
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
builder.Services.AddOpenApi(options =>
@@ -10,7 +10,8 @@
},
"Entra": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
"AllowedAppIds": []
},
"Dev": {
"DefaultUserEmail": "dev@tnsu.kz"
@@ -21,6 +22,12 @@
"https://localhost:4321"
]
},
"SharePoint": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
"CertificateSettingName": "AzurePfx",
"CertificatePassword": "Rk.xDjccnfyjdktybz1"
},
"TnsuPortal": {
"BaseUrl": "https://api.tnsu.kz",
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
@@ -1,6 +1,7 @@
using System.Reflection;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
@@ -11,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
{
services.AddScoped<ReminderService>();
services.AddScoped<RequestTypeService>();
services.AddScoped<TemplateRouteService>();
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&amp;rpmode=approve&amp;rptype=it-1c-ticket&amp;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);
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
{
var items = await db.RequestTypes.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section))
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section, x.SlaHours, x.AskOverdueReason))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
@@ -39,7 +39,9 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
PageUrl = request.PageUrl,
ListId = request.ListId,
SiteUrl = request.SiteUrl,
Section = request.Section
Section = request.Section,
SlaHours = request.SlaHours,
AskOverdueReason = request.AskOverdueReason
};
db.RequestTypes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
@@ -62,6 +64,8 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
entity.ListId = request.ListId;
entity.SiteUrl = request.SiteUrl;
entity.Section = request.Section;
entity.SlaHours = request.SlaHours;
entity.AskOverdueReason = request.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
@@ -85,6 +89,11 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
entity.ListId = listId;
entity.SiteUrl = siteUrl;
entity.Section = request.Section ?? entity.Section;
// PATCH здесь традиционно трактует null как «не менять» (как и остальные
// поля выше), поэтому сбросить SLA обратно в «наследовать» можно только
// через PUT — там значение записывается как есть.
entity.SlaHours = request.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
@@ -102,5 +111,6 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok();
}
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section);
private static RequestTypeDto ToDto(RequestType e) =>
new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section, e.SlaHours, e.AskOverdueReason);
}
@@ -12,6 +12,12 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
RuleFor(x => x)
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours)
.GreaterThanOrEqualTo(0)
.When(x => x.SlaHours is not null)
.WithMessage("SLA в часах не может быть отрицательным.");
}
}
@@ -25,6 +31,12 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<Update
RuleFor(x => x)
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours)
.GreaterThanOrEqualTo(0)
.When(x => x.SlaHours is not null)
.WithMessage("SLA в часах не может быть отрицательным.");
}
}
@@ -38,6 +50,12 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRe
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
When(x => x.Section is not null, () =>
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours)
.GreaterThanOrEqualTo(0)
.When(x => x.SlaHours is not null)
.WithMessage("SLA в часах не может быть отрицательным.");
}
}
@@ -2,6 +2,11 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
// записей, чтобы не ломать существующие позиционные вызовы.
public sealed record TemplateRouteStepDto(
int Id,
int OrderNo,
@@ -15,7 +20,9 @@ public sealed record TemplateRouteStepDto(
string? FieldName,
ComparisonOperator? Operation,
string? Value,
string? Warning = null);
string? Warning = null,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record CreateTemplateRouteStepRequest(
int OrderNo,
@@ -27,7 +34,9 @@ public sealed record CreateTemplateRouteStepRequest(
bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
string? Value,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record UpdateTemplateRouteStepRequest(
int OrderNo,
@@ -39,7 +48,9 @@ public sealed record UpdateTemplateRouteStepRequest(
bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
string? Value,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record PatchTemplateRouteStepRequest(
int? OrderNo,
@@ -51,4 +62,6 @@ public sealed record PatchTemplateRouteStepRequest(
bool? AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
string? Value,
int? SlaHours = null,
bool? AskOverdueReason = null);
@@ -16,7 +16,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
.OrderBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto(
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
x.FieldName, x.Operation, x.Value))
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
@@ -73,7 +73,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
FieldName = r.FieldName,
Operation = r.Operation,
Value = r.Value
Value = r.Value,
SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason
};
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
@@ -125,6 +127,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.FieldName = r.FieldName;
entity.Operation = r.Operation;
entity.Value = r.Value;
entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
@@ -190,6 +194,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.FieldName = fieldName;
entity.Operation = operation;
entity.Value = value;
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
// наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
@@ -281,5 +289,5 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
e.FieldName, e.Operation, e.Value);
e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason);
}
@@ -14,6 +14,12 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours)
.GreaterThanOrEqualTo(0)
.When(x => x.SlaHours is not null)
.WithMessage("SLA в часах не может быть отрицательным.");
}
}
@@ -28,6 +34,12 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours)
.GreaterThanOrEqualTo(0)
.When(x => x.SlaHours is not null)
.WithMessage("SLA в часах не может быть отрицательным.");
}
}
@@ -2,13 +2,26 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
/// <param name="SlaHours">
/// ДЕЙСТВУЮЩИЙ SLA шага в часах — наследование уже применено: значение шага, а
/// если на шаге не задано, то значение типа заявки. null означает, что SLA не
/// настроен нигде, то есть напоминаний по этому шагу не будет.
/// Резолв отдаёт именно итоговое значение, чтобы вызывающая сторона
/// (request-portal) просто записала его в строку списка согласования и не
/// повторяла у себя правило наследования.
/// </param>
/// <param name="AskOverdueReason">
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
/// </param>
public sealed record ResolvedRouteStepDto(
int Id,
int OrderNo,
string? ExecutorEmail,
string? Position,
bool AllowReassignToOtherApprovers,
string? Warning = null);
string? Warning = null,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record ResolveTemplateRouteResultDto(
int RouteId,
@@ -129,8 +129,11 @@ public sealed class TemplateRouteService(
return Result.Fail<ResolveTemplateRouteResultDto>(
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));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
@@ -168,7 +171,8 @@ public sealed class TemplateRouteService(
var steps = new List<ResolvedRouteStepDto>();
foreach (var step in matchingSteps)
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, request.DepartmentId, cancellationToken));
steps.Add(await ResolveStepAsync(
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken));
var collapsedSteps = CollapseByExecutorEmail(steps);
@@ -212,7 +216,8 @@ public sealed class TemplateRouteService(
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
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? warning = null;
@@ -277,7 +282,19 @@ public sealed class TemplateRouteService(
resolvedPosition = resolvedEmployee?.Position;
}
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
return new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
resolvedPosition,
step.AllowReassignToOtherApprovers,
warning,
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason);
}
/// <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; }
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Infrastructure;
@@ -20,6 +21,12 @@ public static class InfrastructureServiceCollectionExtensions
.UseSnakeCaseNamingConvention());
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
services.Configure<SharePointOptions>(configuration.GetSection("SharePoint"));
// Провайдер сертификата — синглтон: разобранный .pfx кешируется на всё
// время жизни процесса (см. DbCertificateProvider).
services.AddSingleton<ICertificateProvider, DbCertificateProvider>();
services.AddHttpClient<ISharePointClient, SharePointClient>();
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>();
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
@@ -8,9 +9,20 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
public DbSet<RequestType> RequestTypes => Set<RequestType>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
public DbSet<AppSetting> Settings => Set<AppSetting>();
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 =>
{
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; }
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -31,6 +31,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
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)")
@@ -59,6 +63,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
@@ -117,6 +125,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
@@ -155,6 +167,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -175,6 +191,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<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")
@@ -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;
}
@@ -3,6 +3,8 @@
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
<PackageReference Include="Azure.Identity" />
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
+4 -10
View File
@@ -3,15 +3,13 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Domain", "Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj", "{67C6FECB-26BF-4A7E-AC40-574F4D88EEAC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Domain", "src\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj", "{67C6FECB-26BF-4A7E-AC40-574F4D88EEAC}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Application", "Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj", "{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Application", "src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj", "{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastructure", "Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj", "{46EF4247-8B98-4CC9-A981-186C8D24163C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastructure", "src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj", "{46EF4247-8B98-4CC9-A981-186C8D24163C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
@@ -92,10 +90,6 @@ Global
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{67C6FECB-26BF-4A7E-AC40-574F4D88EEAC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
-9
View File
@@ -1,9 +0,0 @@
{
"ExpandedNodes": [
"",
"\\Tnsu.ApprovalRoute.Api",
"\\Tnsu.ApprovalRoute.Application"
],
"SelectedNode": "\\Tnsu.ApprovalRoute.Application",
"PreviewInSolutionExplorer": false
}
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; }
}
@@ -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");
}
}
@@ -181,6 +181,80 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
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")]
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
{
@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
<ProjectReference Include="..\..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
<ProjectReference Include="..\..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
</ItemGroup>
<ItemGroup>