Sla #11

Merged
gorbenko.pavel merged 8 commits from SLAendpoint into main 2026-08-13 04:44:08 +00:00
47 changed files with 2481 additions and 52 deletions
Showing only changes of commit 9fc089c37a - Show all commits
+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" />
@@ -1,6 +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";
}
+110 -10
View File
@@ -6,36 +6,136 @@ 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)
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.TokenValidationParameters = new TokenValidationParameters
// Белый список приложений, которым разрешён 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}/"
],
ValidAudiences = [audience, $"api://{audience}"]
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true
};
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthSchemes.Bearer;
options.DefaultChallengeScheme = AuthSchemes.Bearer;
})
.AddJwtBearer(AuthSchemes.Bearer, options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.TokenValidationParameters = validationParameters();
});
services.AddAuthentication()
.AddJwtBearer(AuthSchemes.AppOnly, options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.TokenValidationParameters = validationParameters();
options.Events = new JwtBearerEvents
{
// Подпись, издатель и аудитория у app-only токена те же, что у
// пользовательского, поэтому единственное, что реально отличает
// «свой» вызывающий сервис от любого приложения тенанта, — appid.
OnTokenValidated = context =>
{
var claims = context.Principal?.Claims;
var appId = claims?.FirstOrDefault(c => c.Type == "appid")?.Value
?? claims?.FirstOrDefault(c => c.Type == "azp")?.Value;
if (allowedAppIds.Length > 0 && !allowedAppIds.Contains(appId))
{
context.HttpContext.RequestServices
.GetRequiredService<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)!;
});
}
else
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();
});
}
services.AddAuthorization();
return services;
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();
}
@@ -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": ""
},
"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,95 @@
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;
private static bool TryGetInt(JsonElement e, string name, out int value)
{
value = 0;
return e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out value);
}
private static bool TryGetBool(JsonElement e, string name, out bool value)
{
value = false;
if (!e.TryGetProperty(name, out var v)) return false;
if (v.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
value = v.GetBoolean();
return true;
}
private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) &&
v.ValueKind == JsonValueKind.String &&
DateTimeOffset.TryParse(v.GetString(), out var parsed)
? parsed
: null;
}
internal static class ApprovalPhases
{
public const string Approval = "Согласование";
public const string Execution = "Исполнение";
}
internal static class ApprovalStatuses
{
public const string OnApproval = "На согласовании";
public const string OnExecution = "На исполнении";
}
/// <summary>
/// Managed property статуса строки маршрута для KQL — ApprovalStatusOWSCHCS.
/// Имя нестандартное (суффикс OWSCHCS у Choice-поля, префикс группы колонок
/// сайта у самого имени) — совпадает с MP_STATUS в
/// request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts,
/// это тот же список и та же колонка, имя проверено там же.
/// </summary>
internal static class SlaStatus
{
private const string ManagedProperty = "ApprovalStatusOWSCHCS";
public static readonly string OnApprovalProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnApproval}\"";
public static readonly string OnExecutionProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnExecution}\"";
}
@@ -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,295 @@
using System.Text.Json;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <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;
// Верхняя граница на число одновременно активных шагов по типу заявки —
// запас с большим избытком (это не общий объём заявок, а только те, что
// прямо сейчас на согласовании/исполнении).
private const int SearchRowLimit = 5000;
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. Активные строки — через Search, а не через $filter: дело не в
// кириллице конкретно, а в том, что $filter по Choice-колонке в
// SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо
// ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки
// этого же списка уже проверена и используется в request-portal
// (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут.
//
// Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ
// нерешённые строки списка (queued + active), чтобы отобрать активные
// на клиенте — при большом архиве заявок это росло бы неограниченно.
// Search сужает набор на стороне SharePoint до тех, что реально нужны.
var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken);
if (string.IsNullOrWhiteSpace(relativeUrl))
{
logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle);
return [];
}
var pathClause = $"Path:\"{siteUrl.TrimEnd('/')}{relativeUrl}*\"";
var statusClause = $"({SlaStatus.OnApprovalProperty} OR {SlaStatus.OnExecutionProperty})";
var activeIds = await sharePoint.SearchListItemIdsAsync(
siteUrl, $"{pathClause} AND {statusClause}", SearchRowLimit, cancellationToken);
if (activeIds.Count == 0) return [];
// Search отдаёт только Id — реальные поля (email, SlaHours, TicketId и
// т.д.) дочитываем REST'ом, как и везде в этом сервисе (см. ReadCreatedAsync).
// Заодно и статус здесь берётся из свежих данных, а не из индекса, который
// мог успеть отстать (та же оговорка, что в request-portal).
var active = (await ReadRowsByIdsAsync(siteUrl, approvalListTitle, activeIds, cancellationToken))
.Where(r => r.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution)
.ToList();
if (active.Count == 0) return [];
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
// дата активации шага выводится из дат решений предыдущего шага.
var ticketIds = active.Select(r => r.TicketId).Distinct().ToList();
var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken);
var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList());
var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken);
var reminders = new List<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);
}
/// <summary>Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync).</summary>
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByIdsAsync(
string siteUrl, string listTitle, IReadOnlyList<int> ids, CancellationToken cancellationToken)
{
var rows = new List<ApprovalRow>();
foreach (var chunk in ids.Chunk(IdChunkSize))
{
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
}
return rows;
}
private async Task<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}";
}
@@ -1,9 +1,42 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
// SlaHours/AskOverdueReason добавлены в конец с значением по умолчанию null —
// оба поля необязательны, и старые клиенты, не знающие о них, продолжают
// работать без изменений. null здесь значит «не настроено», а не «ноль»:
// см. RequestType.SlaHours.
public sealed record RequestTypeDto(
int Id,
string Name,
string PageUrl,
string? ListId,
string? SiteUrl,
string Section,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
public sealed record CreateRequestTypeRequest(
string Name,
string PageUrl,
string? ListId,
string? SiteUrl,
string Section,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
public sealed record UpdateRequestTypeRequest(
string Name,
string PageUrl,
string? ListId,
string? SiteUrl,
string Section,
int? SlaHours = null,
bool? AskOverdueReason = null);
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListId, string? SiteUrl, string? Section);
public sealed record PatchRequestTypeRequest(
string? Name,
string? PageUrl,
string? ListId,
string? SiteUrl,
string? Section,
int? SlaHours = null,
bool? AskOverdueReason = null);
@@ -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>
@@ -9,5 +9,21 @@ public class RequestType
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>();
}
@@ -16,6 +16,21 @@ public class TemplateRouteStep
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; }
@@ -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,237 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.Json;
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
public interface ISharePointClient
{
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>
/// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы
/// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync).
/// </summary>
Task<string?> GetListServerRelativeUrlAsync(string siteUrl, string listTitle, 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>
/// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter,
/// сужается набор строк списка согласования по статусу (Choice-колонка) —
/// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в
/// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS,
/// тот же список, тот же managed property, подтверждено рабочим). Значения
/// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом.
/// </summary>
Task<IReadOnlyList<int>> SearchListItemIdsAsync(
string siteUrl, string queryText, int rowLimit, 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 async Task<string?> GetListServerRelativeUrlAsync(
string siteUrl, string listTitle, CancellationToken cancellationToken)
{
var url = $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')" +
"?$select=RootFolder/ServerRelativeUrl&$expand=RootFolder";
var json = await GetAsync(siteUrl, url, cancellationToken);
return ParseServerRelativeUrl(json);
}
public async Task<IReadOnlyList<int>> SearchListItemIdsAsync(
string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken)
{
var url = $"{siteUrl.TrimEnd('/')}/_api/search/query" +
$"?querytext='{Uri.EscapeDataString(queryText)}'" +
"&selectproperties='ListItemID'" +
$"&rowlimit={rowLimit}" +
"&trimduplicates=false";
var json = await GetAsync(siteUrl, url, cancellationToken);
return ParseSearchListItemIds(json);
}
// Разбор вынесен в чистые статические функции — они не ходят в сеть и не
// требуют токена, поэтому их (в отличие от остального клиента) можно
// проверить тестом без живого SharePoint. Это самая непроверенная часть
// клиента: форма ответа Search REST зафиксирована документацией, но без
// реального тенанта убедиться в точном соответствии нельзя.
internal static string? ParseServerRelativeUrl(JsonElement json) =>
json.TryGetProperty("RootFolder", out var rootFolder) &&
rootFolder.TryGetProperty("ServerRelativeUrl", out var relUrl)
? relUrl.GetString()
: null;
internal static IReadOnlyList<int> ParseSearchListItemIds(JsonElement json)
{
// Путь до строк результата — фиксированная форма ответа Search REST,
// не зависит от запроса. С odata=nometadata Cells приходит уже "голым"
// массивом (без обёртки .results) — но на случай, если сервер всё равно
// завернёт его (разные версии API отвечали по-разному на практике в этом
// проекте, см. комментарии в request-portal про managed properties),
// читаем и то, и то (см. Unwrap).
if (!json.TryGetProperty("PrimaryQueryResult", out var primary)) return [];
if (!primary.TryGetProperty("RelevantResults", out var relevant)) return [];
if (!relevant.TryGetProperty("Table", out var table)) return [];
if (!table.TryGetProperty("Rows", out var rowsElement)) return [];
var ids = new List<int>();
foreach (var row in Unwrap(rowsElement))
{
if (!row.TryGetProperty("Cells", out var cellsElement)) continue;
foreach (var cell in Unwrap(cellsElement))
{
if (!cell.TryGetProperty("Key", out var key) || key.GetString() != "ListItemID") continue;
if (!cell.TryGetProperty("Value", out var value)) continue;
if (int.TryParse(value.GetString(), out var id)) ids.Add(id);
}
}
return ids;
}
// odata=nometadata отдаёт коллекции внутри Search-ответа "голым" массивом;
// без этого заголовка (или в старых версиях API) — обёрткой { results: [...] }.
private static IEnumerable<JsonElement> Unwrap(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Array) return element.EnumerateArray();
return element.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array
? results.EnumerateArray()
: [];
}
public Task<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,15 @@
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Даёт тестам доступ к internal-функциям чистого разбора JSON в SharePointClient
(SharePointClientParsingTests) — сам клиент не потестировать без живого
тенанта (аутентификация по сертификату ходит в Azure AD), а разбор ответа
можно и нужно проверять без сети. -->
<InternalsVisibleTo Include="Tnsu.ApprovalRoute.IntegrationTests" />
</ItemGroup>
<ItemGroup>
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
<PackageReference Include="Azure.Identity" />
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
@@ -0,0 +1,285 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <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", int? slaHours = null, bool? ask = null) =>
new
{
Id = id,
TicketId = ticketId,
StepOrder = stepOrder,
ApprovalStatus = status,
Phase = phase,
Date = date,
SLAhours = slaHours,
AskOverdueReason = ask,
TansuSiteApprover = new { EMail = email }
};
private ReminderService BuildService(
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
{
var sharePoint = new Mock<ISharePointClient>();
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
sharePoint
.Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny<CancellationToken>()))
.ReturnsAsync("/sites/requests/Lists/ApprovalListRelUrl");
// Search сужает набор до активных строк (На согласовании/На исполнении) —
// мок отвечает так же, как реальный Search: по статусу строки, не по дате.
var activeRows = allRows
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
"На согласовании" or "На исполнении")
.ToList();
sharePoint
.Setup(x => x.SearchListItemIdsAsync(
SiteUrl, It.Is<string>(q => q.Contains("ApprovalStatusOWSCHCS")), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(activeRows.Select(r => (int)r.GetType().GetProperty("Id")!.GetValue(r)!).ToList());
// Дочитывание полных полей найденных Search'ем строк — по Id. Матчер ищет
// "filter=Id eq", а не просто "Id eq" — иначе он совпал бы и с
// "TicketId eq" (та подстрока в нём есть буквально), и два setup'а
// конфликтовали бы между собой.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("filter=Id 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);
}
[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()
{
@@ -0,0 +1,99 @@
using System.Text.Json;
using Shouldly;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
/// <summary>
/// Разбор ответов SharePoint REST — единственная часть SharePointClient, которую
/// можно проверить без живого тенанта: сам HTTP-вызов неотделим от аутентификации
/// по сертификату (ClientCertificateCredential ходит в Azure AD за токеном перед
/// каждым запросом), а вот разбор уже полученного JSON — чистая функция.
///
/// Это самое непроверенное место всей интеграции: форма ответа Search REST
/// (_api/search/query) задокументирована, но подтвердить её точное соответствие
/// без реального тенанта нельзя — только этими тестами на образцах ответа.
/// </summary>
public sealed class SharePointClientParsingTests
{
private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement;
[Fact(DisplayName = "Search: с odata=nometadata Rows/Cells приходят голыми массивами")]
public void ParseSearchListItemIds_BareArrays_ReturnsIds()
{
const string response = """
{
"PrimaryQueryResult": {
"RelevantResults": {
"RowCount": 2,
"Table": {
"Rows": [
{ "Cells": [ { "Key": "Rank", "Value": "1" }, { "Key": "ListItemID", "Value": "11" } ] },
{ "Cells": [ { "Key": "ListItemID", "Value": "27" }, { "Key": "Rank", "Value": "2" } ] }
]
}
}
}
}
""";
var ids = SharePointClient.ParseSearchListItemIds(Parse(response));
ids.ShouldBe([11, 27]);
}
[Fact(DisplayName = "Search: без odata=nometadata Rows/Cells завёрнуты в {results: [...]}")]
public void ParseSearchListItemIds_WrappedResults_ReturnsIds()
{
const string response = """
{
"PrimaryQueryResult": {
"RelevantResults": {
"Table": {
"Rows": {
"results": [
{ "Cells": { "results": [ { "Key": "ListItemID", "Value": "42" } ] } }
]
}
}
}
}
}
""";
var ids = SharePointClient.ParseSearchListItemIds(Parse(response));
ids.ShouldBe([42]);
}
[Fact(DisplayName = "Search: нет совпадений — пустой список, а не исключение")]
public void ParseSearchListItemIds_NoRows_ReturnsEmpty()
{
const string response = """{ "PrimaryQueryResult": { "RelevantResults": { "RowCount": 0, "Table": { "Rows": [] } } } } """;
SharePointClient.ParseSearchListItemIds(Parse(response)).ShouldBeEmpty();
}
[Fact(DisplayName = "Search: неожиданная форма ответа не роняет разбор")]
public void ParseSearchListItemIds_UnexpectedShape_ReturnsEmpty()
{
SharePointClient.ParseSearchListItemIds(Parse("""{ "odata.error": "что-то не так" }""")).ShouldBeEmpty();
}
[Fact(DisplayName = "RootFolder/ServerRelativeUrl разбирается для условия Path в KQL")]
public void ParseServerRelativeUrl_ReturnsValue()
{
const string response = """
{ "RootFolder": { "ServerRelativeUrl": "/sites/requests/Lists/Заявка тест — согласование" } }
""";
SharePointClient.ParseServerRelativeUrl(Parse(response))
.ShouldBe("/sites/requests/Lists/Заявка тест — согласование");
}
[Fact(DisplayName = "Список без RootFolder в ответе — null, а не исключение")]
public void ParseServerRelativeUrl_Missing_ReturnsNull()
{
SharePointClient.ParseServerRelativeUrl(Parse("{}")).ShouldBeNull();
}
}
@@ -6,6 +6,7 @@ import { texts } from '../../texts';
import { toEmployeeOption } from '../../utils/employee';
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
import { ActionsCell, Badge, BadgeVariant, CellNote, IconButton, PrimaryCell, SearchableSelect } from '../ui';
import { formatSlaHours } from '../../utils/sla';
export interface IManageRequestsPhaseTableProps {
title: string;
@@ -26,6 +27,7 @@ export interface IManageRequestsPhaseTableProps {
requestRejected: boolean;
statusVariant: (status: string) => BadgeVariant;
onReassign: (row: ManagedApprovalRow) => void;
onEditSla: (row: ManagedApprovalRow) => void;
onRemove: (row: ManagedApprovalRow) => void;
onNotify: (row: ManagedApprovalRow) => void;
/** Id строки, для которой сейчас в процессе отправка уведомления — кнопка на ней отключена. */
@@ -79,6 +81,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
requestRejected,
statusVariant,
onReassign,
onEditSla,
onRemove,
onNotify,
notifyingRowId,
@@ -187,6 +190,7 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
<th>{texts.manageRequests.columnPosition}</th>
<th>{texts.manageRequests.columnStatus}</th>
<th>{texts.manageRequests.columnDate}</th>
<th>{texts.manageRequests.columnSla}</th>
{!readOnly && <th />}
{!readOnly && <th />}
</tr>
@@ -244,6 +248,10 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
<td>{r.position || texts.common.empty}</td>
<td><Badge variant={statusVariant(r.status)}>{r.status || texts.common.empty}</Badge></td>
<td>{r.date || texts.common.empty}</td>
<td>
{formatSlaHours(r.slaHours)}
{r.slaHours === null && <CellNote>{texts.manageRequests.slaInherited}</CellNote>}
</td>
{/* Стрелки перестановки — СЛЕВА от действий над строкой (объединить/
переназначить/удалить), одна пара на весь блок (rowSpan): двигают
шаг целиком, а не отдельного участника параллельной группы. */}
@@ -293,6 +301,12 @@ export const ManageRequestsPhaseTable: React.FC<IManageRequestsPhaseTableProps>
label={texts.manageRequests.reassign}
onClick={() => onReassign(r)}
/>
<IconButton
icon="flag"
className={styles.iconBtnVisible}
label={texts.manageRequests.editSla}
onClick={() => onEditSla(r)}
/>
<IconButton
icon="trash"
className={styles.iconBtnVisible}
@@ -2,6 +2,7 @@ import * as React from 'react';
import styles from './ManageRequestsScreen.module.scss';
import { ManageRequestsApproverModal } from './ManageRequestsApproverModal';
import { ManageRequestsPhaseTable } from './ManageRequestsPhaseTable';
import { ManageRequestsSlaModal } from './ManageRequestsSlaModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RequestManageService } from '../../services/requestManageService';
@@ -178,6 +179,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const [matrixLoading, setMatrixLoading] = React.useState(false);
const [matrixError, setMatrixError] = React.useState<string | undefined>();
const [modal, setModal] = React.useState<ManagedApprovalRow | undefined>();
const [slaRow, setSlaRow] = React.useState<ManagedApprovalRow | undefined>();
const [deleteRow, setDeleteRow] = React.useState<ManagedApprovalRow | undefined>();
const [saveError, setSaveError] = React.useState<string | undefined>();
const [notifyResult, setNotifyResult] = React.useState<{ message: string; kind: 'ok' | 'err' } | undefined>();
@@ -348,6 +350,31 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
const openReassign = (row: ManagedApprovalRow): void => setModal(row);
const closeModal = (): void => setModal(undefined);
const openSla = (row: ManagedApprovalRow): void => setSlaRow(row);
const closeSla = (): void => setSlaRow(undefined);
// SLA правится так же оптимистично, как переназначение: строка в матрице
// обновляется по ответу сервера, без перезагрузки всей матрицы.
const saveSla = (
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», работает SLA типа заявки
slaHours: number | null,
// eslint-disable-next-line @rushstack/no-new-null -- см. выше
askOverdueReason: boolean | null
): Promise<void> => {
if (!matrix || !slaRow) return Promise.resolve();
setSaveError(undefined);
const rowId = slaRow.id;
return requestManage
.updateRowSla(matrix, rowId, slaHours, askOverdueReason)
.then(() => {
setMatrix(prev => (prev ? patchRow(prev, rowId, { slaHours, askOverdueReason }) : prev));
closeSla();
})
.catch((e: Error) => {
setSaveError(reportError(e, 'saveSla: updateRowSla'));
throw e;
});
};
// Кого нельзя выбрать при переназначении — все остальные согласующие/
// исполнители ТОЙ ЖЕ фазы (сама строка modal исключена, иначе нельзя было
@@ -424,7 +451,11 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
comment: '',
order,
allowReassign: false,
phase
phase,
// Шаг, добавленный вручную, маршрутом-шаблоном не порождён и своего SLA не
// имеет — работает SLA типа заявки, пока его не зададут отдельно.
slaHours: null,
askOverdueReason: null
};
const previous = matrix;
@@ -548,6 +579,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
@@ -564,6 +596,7 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
requestRejected={requestRejected}
statusVariant={statusVariant}
onReassign={openReassign}
onEditSla={openSla}
onRemove={askDelete}
onNotify={handleNotify}
notifyingRowId={notifyingRowId}
@@ -590,6 +623,16 @@ export const ManageRequestsScreen: React.FC<IManageRequestsScreenProps> = ({ api
/>
)}
{slaRow && (
<ManageRequestsSlaModal
orderNo={slaRow.order}
slaHours={slaRow.slaHours}
askOverdueReason={slaRow.askOverdueReason}
onSave={saveSla}
onClose={closeSla}
/>
)}
{deleteRow && (
<ConfirmDialog
title={texts.manageRequests.deleteTitle}
@@ -0,0 +1,84 @@
import * as React from 'react';
import { texts } from '../../texts';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import {
boolToTriState,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool,
TriState,
TRI_STATE_OPTIONS
} from '../../utils/sla';
import { Field, FormGrid, FormModal, Select, TextInput } from '../ui';
export interface IManageRequestsSlaModalProps {
/** Номер шага — только для заголовка, чтобы было видно, что правим. */
orderNo: number;
// eslint-disable-next-line @rushstack/no-new-null -- null = «в строке не задано», это не 0
slaHours: number | null;
// eslint-disable-next-line @rushstack/no-new-null -- null = «в строке не задано»
askOverdueReason: boolean | null;
// eslint-disable-next-line @rushstack/no-new-null -- см. выше
onSave: (slaHours: number | null, askOverdueReason: boolean | null) => Promise<void>;
onClose: () => void;
}
/**
* SLA конкретной строки маршрута конкретной заявки.
*
* Правится отдельно от маршрута-шаблона: шаблон задаёт значение на момент
* создания заявки, а здесь его можно поменять для одной уже идущей заявки, не
* трогая остальные.
*
* Пустое поле сохраняется как «не задано» — тогда работает SLA типа заявки.
* Ноль пустотой не считается: это «напоминать сразу».
*/
export const ManageRequestsSlaModal: React.FC<IManageRequestsSlaModalProps> = ({
orderNo,
slaHours,
askOverdueReason,
onSave,
onClose
}) => {
const [hours, setHours] = React.useState(() => slaHoursToInput(slaHours));
const [ask, setAsk] = React.useState<TriState>(() => boolToTriState(askOverdueReason));
const submit = useAsyncSubmit();
const handleSubmit = (): void => {
if (!isValidSlaHours(hours)) {
submit.fail(texts.manageRequests.validationSlaHours);
return;
}
submit.run(() => onSave(parseSlaHours(hours), triStateToBool(ask)));
};
return (
<FormModal
title={texts.manageRequests.slaModalTitle(orderNo)}
error={submit.error}
isSaving={submit.isBusy}
onSubmit={handleSubmit}
onClose={onClose}
>
<FormGrid>
<Field label={texts.manageRequests.slaHoursLabel} full hint={texts.manageRequests.slaHoursHint}>
<TextInput
type="number"
min={0}
value={hours}
onChange={e => setHours(e.target.value)}
placeholder={texts.manageRequests.slaHoursPlaceholder}
/>
</Field>
<Field label={texts.manageRequests.askOverdueReasonLabel} full hint={texts.manageRequests.askOverdueReasonHint}>
<Select
options={TRI_STATE_OPTIONS}
value={ask}
onChange={e => setAsk(e.target.value as TriState)}
/>
</Field>
</FormGrid>
</FormModal>
);
};
@@ -3,6 +3,16 @@ import { ApprovalRouteApi } from '../../services/api';
import { formatSiteLabel, SharePointListsClient, SharePointSiteSuggestion } from '../../services/sharePointLists';
import { RequestType } from '../../models';
import { texts } from '../../texts';
import {
boolToTriState,
formatSlaHours,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool,
TriState,
TRI_STATE_OPTIONS
} from '../../utils/sla';
import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useSharePointSiteTitle } from '../../hooks/useSharePointSiteTitle';
@@ -38,9 +48,14 @@ interface IFormState {
section: string;
siteUrl: 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-контракт бэкенда
// (см. models/requestTypes.ts) — здесь сразу приводим null к undefined,
@@ -88,7 +103,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listId: item.listId ?? ''
listId: item.listId ?? '',
slaHours: slaHoursToInput(item.slaHours),
askOverdueReason: boolToTriState(item.askOverdueReason)
});
setShowForm(true);
};
@@ -104,6 +121,10 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
submit.fail(texts.requestTypes.validationListReference);
return;
}
if (!isValidSlaHours(form.slaHours)) {
submit.fail(texts.requestTypes.validationSlaHours);
return;
}
const input = {
name: form.name.trim(),
@@ -114,7 +135,9 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sh
// eslint-disable-next-line @rushstack/no-new-null
siteUrl: siteUrl || 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);
@@ -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: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ 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: '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 })}
/>
</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>
</FormModal>
)}
@@ -18,6 +18,15 @@ import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
import {
boolToTriState,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool,
TriState,
TRI_STATE_OPTIONS
} from '../../utils/sla';
export interface IRouteStepFormModalProps {
api: ApprovalRouteApi;
@@ -47,6 +56,9 @@ interface IFormState {
fieldName: string;
operation: ComparisonOperator;
value: string;
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
slaHours: string;
askOverdueReason: TriState;
}
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
@@ -78,7 +90,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
value: ''
value: '',
slaHours: '',
askOverdueReason: ''
};
}
return {
@@ -93,7 +107,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
value: editing.value ?? ''
value: editing.value ?? '',
slaHours: slaHoursToInput(editing.slaHours),
askOverdueReason: boolToTriState(editing.askOverdueReason)
};
}
@@ -164,6 +180,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationConditionNumber);
return;
}
if (!isValidSlaHours(form.slaHours)) {
submit.fail(texts.routeSteps.validationSlaHours);
return;
}
const payload = {
orderNo,
@@ -175,7 +195,9 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
value: form.hasCondition ? form.value.trim() : null
value: form.hasCondition ? form.value.trim() : null,
slaHours: parseSlaHours(form.slaHours),
askOverdueReason: triStateToBool(form.askOverdueReason)
};
const request = editing
@@ -283,6 +305,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/>
</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}>
<Checkbox
label={texts.routeSteps.conditionCheckbox}
@@ -101,6 +101,17 @@ export interface ManagedApprovalRow {
order: number;
allowReassign: boolean;
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 {
@@ -10,6 +10,14 @@ export interface RequestType {
/** Сайт SharePoint, где живёт listId. Заполнен тогда и только тогда, когда заполнен listId. */
siteUrl: string | null;
section: string;
/**
* SLA по умолчанию для всех шагов заявок этого типа, в часах.
* null — SLA не настроен, напоминания по типу не строятся.
* Отличать null от 0 обязательно: 0 значит «напоминать сразу».
*/
slaHours: number | null;
/** Спрашивать ли причину просрочки — значение по умолчанию для шагов. null — не задано. */
askOverdueReason: boolean | null;
}
export interface CreateRequestTypeInput {
@@ -18,6 +26,8 @@ export interface CreateRequestTypeInput {
listId: string | null;
siteUrl: string | null;
section: string;
slaHours: number | null;
askOverdueReason: boolean | null;
}
export type UpdateRequestTypeInput = CreateRequestTypeInput;
@@ -28,4 +38,6 @@ export interface PatchRequestTypeInput {
listId?: string | null;
siteUrl?: string | null;
section?: string;
slaHours?: number | null;
askOverdueReason?: boolean | null;
}
@@ -19,6 +19,13 @@ export interface TemplateRouteStep {
operation: ComparisonOperator | null;
/** Значение для сравнения с fieldName. */
value: string | null;
/**
* SLA шага в часах — перекрывает SLA типа заявки.
* null — «не задано, наследовать»; 0 — «напоминать сразу».
*/
slaHours: number | null;
/** Спрашивать ли причину просрочки на этом шаге. null — наследовать от типа. */
askOverdueReason: boolean | null;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null;
}
@@ -34,6 +41,8 @@ export interface CreateTemplateRouteStepInput {
fieldName: string | null;
operation: ComparisonOperator | null;
value: string | null;
slaHours: number | null;
askOverdueReason: boolean | null;
}
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
@@ -49,6 +58,8 @@ export interface PatchTemplateRouteStepInput {
fieldName?: string | null;
operation?: ComparisonOperator | null;
value?: string | null;
slaHours?: number | null;
askOverdueReason?: boolean | null;
}
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
@@ -42,7 +42,10 @@ const FIELDS = {
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase'
phase: 'Phase',
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
slaHours: 'SLAhours',
askOverdueReason: 'AskOverdueReason'
};
function siteOrigin(siteUrl: string): string {
@@ -220,6 +223,8 @@ export class RequestManageService {
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
FIELDS.slaHours,
FIELDS.askOverdueReason,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
@@ -241,7 +246,11 @@ export class RequestManageService {
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? i + 1,
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
};
});
@@ -271,6 +280,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(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
@@ -59,6 +59,12 @@ export const texts = {
loading: 'Загрузка...',
yes: 'Да',
no: 'Нет',
/**
* Третье состояние для настроек с наследованием (SLA, опрос причины
* просрочки): пусто значит «взять значение уровнем выше», и это НЕ то же
* самое, что «Нет» или «0» — их выбирают осознанно.
*/
inherited: 'Не задано',
/** Прочерк вместо пустого значения — чтобы ячейка таблицы не выглядела сломанной. */
empty: '—'
},
@@ -107,6 +113,7 @@ export const texts = {
columnSection: 'Раздел',
columnListId: 'Список SharePoint',
columnSiteUrl: 'Сайт SharePoint',
columnSla: 'SLA',
createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки',
nameLabel: 'Название',
@@ -126,6 +133,14 @@ export const texts = {
listIdLoading: 'Загрузка списков сайта...',
listIdEmpty: 'На этом сайте нет доступных списков',
listIdError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
slaHoursLabel: 'SLA, часов',
slaHoursHint:
'Через сколько часов простоя шага напоминать. Пусто — напоминания по этому типу заявок не работают. ' +
'Отсчёт идёт от момента, когда шаг стал активным.',
slaHoursPlaceholder: 'Например, 24',
askOverdueReasonLabel: 'Спрашивать причину просрочки',
askOverdueReasonHint: 'Значение по умолчанию для всех шагов этого типа заявок.',
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
validationRequired: 'Заполните имя, Url страницы и раздел.',
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
deleteTitle: 'Удалить тип заявки?',
@@ -209,6 +224,12 @@ export const texts = {
headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим',
slaHoursLabel: 'SLA шага, часов',
slaHoursHint:
'Пусто — берётся SLA типа заявки. Заданное здесь значение его перекрывает; 0 означает «напоминать сразу».',
slaHoursPlaceholder: 'Как у типа заявки',
askOverdueReasonLabel: 'Спрашивать причину просрочки',
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
@@ -225,6 +246,7 @@ export const texts = {
validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.',
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
},
@@ -318,6 +340,17 @@ export const texts = {
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
moveBlockedByDecision: 'Нельзя переставить — соседний шаг уже решён.',
reassignTitle: 'Переназначить шаг',
columnSla: 'SLA',
slaInherited: 'от типа заявки',
editSla: 'Настроить SLA шага',
slaModalTitle: (orderNo: number): string => `SLA шага ${orderNo}`,
slaHoursLabel: 'SLA, часов',
slaHoursHint:
'Через сколько часов простоя напоминать по этому шагу. Пусто — берётся SLA типа заявки; 0 — напоминать сразу.',
slaHoursPlaceholder: 'Как у типа заявки',
askOverdueReasonLabel: 'Спрашивать причину просрочки',
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
approverLabel: 'Сотрудник',
approverPlaceholder: 'Выберите сотрудника',
validationApprover: 'Выберите сотрудника.',
@@ -0,0 +1,86 @@
import {
boolToTriState,
formatSlaHours,
isValidSlaHours,
parseSlaHours,
slaHoursToInput,
triStateToBool
} from './sla';
// Смысл всех этих проверок один: «не задано» и «ноль/нет» — РАЗНЫЕ состояния.
// Если они где-то схлопнутся, шаг с настройкой «напоминать сразу» станет
// неотличим от шага без настройки, и напоминания по нему просто не придут.
describe('parseSlaHours', () => {
it('пустая строка — «не задано», а не ноль', () => {
expect(parseSlaHours('')).toBeNull();
expect(parseSlaHours(' ')).toBeNull();
});
it('ноль остаётся нулём', () => {
expect(parseSlaHours('0')).toBe(0);
});
it('число разбирается', () => {
expect(parseSlaHours('24')).toBe(24);
expect(parseSlaHours(' 48 ')).toBe(48);
});
it('мусор не превращается в ноль', () => {
expect(parseSlaHours('abc')).toBeNull();
expect(parseSlaHours('-5')).toBeNull();
expect(parseSlaHours('1.5')).toBeNull();
});
});
describe('isValidSlaHours', () => {
it('пусто — допустимо, это «не задано»', () => {
expect(isValidSlaHours('')).toBe(true);
});
it('ноль и целые положительные — допустимы', () => {
expect(isValidSlaHours('0')).toBe(true);
expect(isValidSlaHours('24')).toBe(true);
});
it('отрицательное, дробное и нечисловое — недопустимы', () => {
expect(isValidSlaHours('-1')).toBe(false);
expect(isValidSlaHours('1.5')).toBe(false);
expect(isValidSlaHours('abc')).toBe(false);
});
});
describe('slaHoursToInput', () => {
it('ноль показывается нулём, а не пустотой', () => {
expect(slaHoursToInput(0)).toBe('0');
});
it('«не задано» показывается пустым полем', () => {
expect(slaHoursToInput(null)).toBe('');
expect(slaHoursToInput(undefined)).toBe('');
});
});
describe('formatSlaHours', () => {
it('ноль часов остаётся нулём, а не прочерком', () => {
expect(formatSlaHours(0)).toBe('0 ч');
});
it('не задано — прочерк', () => {
expect(formatSlaHours(null)).toBe('—');
});
});
describe('triState', () => {
it('false и «не задано» — разные значения', () => {
expect(triStateToBool('no')).toBe(false);
expect(triStateToBool('')).toBeNull();
});
it('преобразование в обе стороны сохраняет все три состояния', () => {
expect(boolToTriState(true)).toBe('yes');
expect(boolToTriState(false)).toBe('no');
expect(boolToTriState(null)).toBe('');
expect(triStateToBool(boolToTriState(false))).toBe(false);
expect(triStateToBool(boolToTriState(null))).toBeNull();
});
});
@@ -0,0 +1,70 @@
import { texts } from '../texts';
/**
* Настройки SLA существуют в ТРЁХ состояниях, и это главное, что здесь важно:
* «не задано» (наследовать уровнем выше), «задано значение» и — отдельно —
* осознанные 0 часов / «Нет». Если бы пустое поле схлопывалось в 0 или false,
* шаг с настройкой «напоминать сразу» стал бы неотличим от шага без настройки.
*
* Формы хранят эти поля строками (так работают TextInput/Select), поэтому
* преобразование строка ↔ nullable-значение бэкенда собрано в одном месте, а не
* повторено в каждой форме.
*/
/** Значение выпадающего списка для nullable-флага. */
export type TriState = '' | 'yes' | 'no';
export const TRI_STATE_OPTIONS: Array<{ value: TriState; label: string }> = [
{ value: '', label: texts.common.inherited },
{ value: 'yes', label: texts.common.yes },
{ value: 'no', label: texts.common.no }
];
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function triStateToBool(value: TriState): boolean | null {
if (value === 'yes') return true;
if (value === 'no') return false;
return null;
}
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function boolToTriState(value: boolean | null | undefined): TriState {
if (value === true) return 'yes';
if (value === false) return 'no';
return '';
}
/**
* Строка поля ввода → часы. Пустая строка — «не задано» (null), а не 0.
* Некорректный ввод тоже даёт null: значение проверяется отдельно
* (см. isValidSlaHours), молча превращать «abc» в 0 нельзя.
*/
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function parseSlaHours(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
}
/** Пусто — допустимо («не задано»); заполнено — должно быть целым неотрицательным числом. */
export function isValidSlaHours(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return true;
const parsed = Number(trimmed);
return Number.isInteger(parsed) && parsed >= 0;
}
/** Часы из бэкенда → строка поля ввода. Ноль обязан остаться нулём, а не стать пустотой. */
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function slaHoursToInput(value: number | null | undefined): string {
return value === null || value === undefined ? '' : String(value);
}
/** Часы для показа в таблице: «—» вместо пустого значения, «0 ч» остаётся нулём. */
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит nullable-контракт бэкенда
export function formatSlaHours(value: number | null | undefined): string {
return value === null || value === undefined ? texts.common.empty : `${value} ч`;
}
@@ -15,6 +15,8 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
fieldName: null,
operation: null,
value: null,
slaHours: null,
askOverdueReason: null,
...overrides
};
}