основное сделано
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuers =
|
||||
[
|
||||
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||
$"https://sts.windows.net/{tenantId}/"
|
||||
],
|
||||
ValidAudiences = [audience, $"api://{audience}"]
|
||||
};
|
||||
});
|
||||
}
|
||||
AddEntraAuth(services, configuration, tenantId, audience);
|
||||
else
|
||||
{
|
||||
services.AddAuthentication(AuthSchemes.Dev)
|
||||
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||
}
|
||||
|
||||
services.AddAuthorization();
|
||||
AddDevAuth(services);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void AddEntraAuth(
|
||||
IServiceCollection services, IConfiguration configuration, string tenantId, string audience)
|
||||
{
|
||||
// Белый список приложений, которым разрешён app-only доступ. Пустой список
|
||||
// означает «не ограничивать» — иначе на пустой конфигурации сломались бы
|
||||
// вообще все app-only вызовы, что при выкладке выглядит как необъяснимый 401.
|
||||
var allowedAppIds = configuration.GetSection("Entra:AllowedAppIds").Get<string[]>() ?? [];
|
||||
|
||||
var validationParameters = () => new TokenValidationParameters
|
||||
{
|
||||
ValidAudiences = [audience, $"api://{audience}"],
|
||||
ValidIssuers =
|
||||
[
|
||||
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||
$"https://sts.windows.net/{tenantId}/"
|
||||
],
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true
|
||||
};
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = AuthSchemes.Bearer;
|
||||
options.DefaultChallengeScheme = AuthSchemes.Bearer;
|
||||
})
|
||||
.AddJwtBearer(AuthSchemes.Bearer, options =>
|
||||
{
|
||||
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||
options.TokenValidationParameters = validationParameters();
|
||||
});
|
||||
|
||||
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)!;
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddDevAuth(IServiceCollection services)
|
||||
{
|
||||
services.AddAuthentication(AuthSchemes.Dev)
|
||||
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||
|
||||
// Те же имена политик, но поверх Dev-схемы: иначе контроллер с
|
||||
// [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
|
||||
{
|
||||
options.AddPolicy(name, policy =>
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
|
||||
policy.RequireAuthenticatedUser();
|
||||
});
|
||||
}
|
||||
|
||||
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Просроченные шаги согласования и исполнения на текущий момент.
|
||||
///
|
||||
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
||||
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
||||
/// открыть под пользователем, чтобы посмотреть, что сервис реально отдаёт.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Any)]
|
||||
[Route("api/reminders")]
|
||||
[Produces("application/json")]
|
||||
public sealed class RemindersController(ReminderService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||
(await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
||||
}
|
||||
@@ -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&rpmode=approve&rptype=it-1c-ticket&rpid=11
|
||||
///
|
||||
/// {pageUrl} — это PageUrl типа заявки, то есть КЛЮЧ ФОРМЫ (it-1c-ticket), а
|
||||
/// не путь к странице: в портале он и подставляется в rptype.
|
||||
///
|
||||
/// Вынесено в конфигурацию, чтобы смена разметки ссылок в портале правилась
|
||||
/// настройкой, а не пересборкой сервиса.
|
||||
/// </summary>
|
||||
public string RequestUrlTemplate { get; set; } =
|
||||
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
|
||||
+12
@@ -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; }
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||
[Migration("20260812091325_AddSlaSettings")]
|
||||
partial class AddSlaSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool?>("AskOverdueReason")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("ask_overdue_reason");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("PageUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("page_url");
|
||||
|
||||
b.Property<string>("Section")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("section");
|
||||
|
||||
b.Property<string>("SiteUrl")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasColumnName("site_url");
|
||||
|
||||
b.Property<int?>("SlaHours")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sla_hours");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_request_types");
|
||||
|
||||
b.ToTable("request_types", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("ByProject")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("by_project");
|
||||
|
||||
b.Property<Guid?>("CompanyId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("company_id");
|
||||
|
||||
b.Property<Guid?>("DepartmentId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("department_id");
|
||||
|
||||
b.Property<int>("RequestTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("request_type_id");
|
||||
|
||||
b.Property<bool>("RequiresControl")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("requires_control");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_template_routes");
|
||||
|
||||
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||
|
||||
b.ToTable("template_routes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("allow_reassign_to_other_approvers");
|
||||
|
||||
b.Property<bool?>("AskOverdueReason")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("ask_overdue_reason");
|
||||
|
||||
b.Property<string>("ExecutorEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("executor_email");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("field_name");
|
||||
|
||||
b.Property<string>("HeadType")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("head_type");
|
||||
|
||||
b.Property<string>("Operation")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<int>("OrderNo")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("order_no");
|
||||
|
||||
b.Property<string>("Position")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("position");
|
||||
|
||||
b.Property<string>("ProjectRole")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("project_role");
|
||||
|
||||
b.Property<int>("RouteId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("route_id");
|
||||
|
||||
b.Property<int?>("SlaHours")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sla_hours");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_template_route_steps");
|
||||
|
||||
b.HasIndex("RouteId", "OrderNo")
|
||||
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||
|
||||
b.ToTable("template_route_steps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("OptionName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("option_name");
|
||||
|
||||
b.Property<byte[]>("FileData")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("file_data");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("OptionName")
|
||||
.HasName("pk_settings");
|
||||
|
||||
b.ToTable("settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||
.WithMany("Routes")
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||
|
||||
b.Navigation("RequestType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||
{
|
||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||
.WithMany("Steps")
|
||||
.HasForeignKey("RouteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||
|
||||
b.Navigation("Route");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||
{
|
||||
b.Navigation("Routes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSlaSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ask_overdue_reason",
|
||||
table: "template_route_steps",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "sla_hours",
|
||||
table: "template_route_steps",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ask_overdue_reason",
|
||||
table: "request_types",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "sla_hours",
|
||||
table: "request_types",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "settings",
|
||||
columns: table => new
|
||||
{
|
||||
option_name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
file_data = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||
value = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_settings", x => x.option_name);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "settings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ask_overdue_reason",
|
||||
table: "template_route_steps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "sla_hours",
|
||||
table: "template_route_steps");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ask_overdue_reason",
|
||||
table: "request_types");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "sla_hours",
|
||||
table: "request_types");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -31,6 +31,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
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" />
|
||||
|
||||
+285
@@ -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");
|
||||
}
|
||||
}
|
||||
+74
@@ -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()
|
||||
{
|
||||
|
||||
+99
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user