Обходной лист (#12)
Tests / backend-test (push) Successful in 1m38s
Tests / frontend-test (push) Successful in 47s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-17 05:43:52 +00:00
parent d076f4e98d
commit 48ede1420d
37 changed files with 1351 additions and 435 deletions
@@ -4,9 +4,6 @@ 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;
@@ -20,11 +20,11 @@ public static class AuthSetup
var tenantId = configuration["Entra:TenantId"];
var audience = configuration["Entra:Audience"];
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
AddEntraAuth(services, configuration, tenantId, audience);
else
AddDevAuth(services);
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
throw new InvalidOperationException(
"Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
AddEntraAuth(services, configuration, tenantId, audience);
return services;
}
@@ -125,26 +125,4 @@ public static class AuthSetup
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)!;
});
}
}
@@ -1,26 +0,0 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Api.Auth;
public sealed class DevAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
?? configuration["Dev:DefaultUserEmail"]
?? "dev@tnsu.kz";
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -1,8 +1,4 @@
{
"Entra": {
"TenantId": "",
"Audience": ""
},
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
}
@@ -13,9 +13,6 @@
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
"AllowedAppIds": []
},
"Dev": {
"DefaultUserEmail": "dev@tnsu.kz"
},
"Cors": {
"AllowedOrigins": [
"https://tnsukz.sharepoint.com",
@@ -34,6 +31,7 @@
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
"ClientSecret": "",
"Scope": "",
"DirectorTansuEmail": "a.samayeva@tnsu.kz"
"DirectorTansuEmail": "a.samayeva@tnsu.kz",
"ManagingDirectorTansuEmail": "nkim@tnsu.kz"
}
}
@@ -11,7 +11,6 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
internal sealed record ApprovalRow(
int Id,
int TicketId,
int StepOrder,
string Status,
string Phase,
DateTimeOffset? Date,
@@ -31,7 +30,6 @@ internal sealed record ApprovalRow(
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).
@@ -23,10 +23,6 @@ public sealed class ReminderService(
IOptions<RemindersOptions> options,
ILogger<ReminderService> logger)
{
// Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе
// на большом списке получится неподъёмной длины URL.
private const int IdChunkSize = 50;
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
private const string ActiveStatusFilter =
@@ -81,7 +77,7 @@ public sealed class ReminderService(
return [];
}
// Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus.
// Активные строки — прямым $filter по полю ApprovalStatus.
//
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
@@ -91,29 +87,28 @@ public sealed class ReminderService(
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
// он не отстаёт от реального состояния списка (не зависит от индексации
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
//
// Дата активации строки — прямо в ней самой (колонка Date): request-portal
// проставляет её в момент перехода строки в «На согласовании»/«На
// исполнении» (см. approvalList.service.ts/setActive) и никак иначе,
// поэтому больше не нужно ни читать весь маршрут заявки, ни дату
// создания заявки, ни выводить активацию из решений соседних шагов.
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
if (active.Count == 0) return [];
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
// дата активации шага выводится из дат решений предыдущего шага.
var ticketIds = active.Select(r => r.TicketId).Distinct().ToList();
var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken);
var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList());
var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken);
var reminders = new List<ReminderDto>();
foreach (var row in active)
{
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue;
createdByTicket.TryGetValue(row.TicketId, out var created);
var activatedAt = ActivatedAt(row, ticketRows, created);
if (activatedAt is null)
if (row.Date is not { } activatedAt)
{
// Строка активна, но Date пуста — так бывает у строк, заведённых
// ДО появления колонки (см. ApprovalRow) или если request-portal
// почему-то не проставил её при активации. Без даты старта SLA не
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
logger.LogWarning(
"Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена",
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
row.Id, row.TicketId);
continue;
}
@@ -121,7 +116,7 @@ public sealed class ReminderService(
// SLA строки перекрывает типовой. У строк, созданных до появления
// колонки, значения нет — тогда работает SLA типа заявки.
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
var deadline = activatedAt.Value.AddHours(slaHours);
var deadline = activatedAt.AddHours(slaHours);
if (deadline >= now) continue;
reminders.Add(new ReminderDto(
@@ -137,51 +132,6 @@ public sealed class ReminderService(
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>
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
@@ -207,7 +157,7 @@ public sealed class ReminderService(
}
private const string RowSelect =
"$select=Id,ApprovalStatus,StepOrder,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
@@ -219,43 +169,6 @@ public sealed class ReminderService(
return Parse(items);
}
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
{
var rows = new List<ApprovalRow>();
foreach (var chunk in ticketIds.Chunk(IdChunkSize))
{
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"TicketId eq {id}"));
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
}
return rows;
}
/// <summary>Даты создания заявок — начало отсчёта для первого шага согласования.</summary>
private async Task<Dictionary<int, DateTimeOffset>> ReadCreatedAsync(
string siteUrl, string listUrl, IReadOnlyList<int> ids, CancellationToken cancellationToken)
{
var created = new Dictionary<int, DateTimeOffset>();
foreach (var chunk in ids.Chunk(IdChunkSize))
{
var query = "$select=Id,Created&$top=5000&$filter=" +
string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listUrl, query, cancellationToken);
foreach (var item in items)
{
if (!item.TryGetProperty("Id", out var idValue) || !idValue.TryGetInt32(out var id)) continue;
if (!item.TryGetProperty("Created", out var createdValue)) continue;
if (createdValue.ValueKind != JsonValueKind.String) continue;
if (DateTimeOffset.TryParse(createdValue.GetString(), out var parsed)) created[id] = parsed;
}
}
return created;
}
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
}
@@ -163,16 +163,42 @@ public sealed class TemplateRouteService(
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
// Синтетическое поле условия — переопределяет то, что мог прислать вызывающий
// код под тем же именем. Считаем НЕ по должности инициатора из справочника
// api.tnsu.kz (employee.Position — она у самого сотрудника чаще всего не
// «руководитель»), а по полю Position, заполненному В САМОЙ АНКЕТЕ (например,
// «Должность» в Обходном листе — там указывают должность увольняемого, а не
// того, кто отправил заявку). Это свободный текст, поэтому определяем
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
// считаем «нет» (а не подставляем должность инициатора как фолбэк).
fieldValues.TryGetValue(PositionField, out var formPosition);
fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
// Должность в заявке не передана вовсе — условие по IsManager вычислить не из
// чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
// лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
// ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
// руководящую, — это осознанный «нет», предупреждать не о чем.
var positionMissing = string.IsNullOrWhiteSpace(formPosition);
var matchingSteps = route.Steps
.Where(s => s.Status == request.Status)
.Where(s => MatchesCondition(s, fieldValues))
.Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
.OrderBy(s => s.OrderNo)
.ToList();
var steps = new List<ResolvedRouteStepDto>();
foreach (var step in matchingSteps)
steps.Add(await ResolveStepAsync(
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken));
// Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные
// походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте
// (десяток+ шагов) последовательные await один за другим складываются в
// заметную задержку. Task.WhenAll сохраняет порядок результатов (массив
// соответствует порядку задач, а не порядку завершения), поэтому
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
// дубликат считается первым) продолжают работать так же, как при
// последовательном foreach.
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
cancellationToken)));
var collapsedSteps = CollapseByExecutorEmail(steps);
@@ -214,9 +240,16 @@ public sealed class TemplateRouteService(
x.ByProject == byProject,
cancellationToken);
/// <param name="conditionWarning">
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param>
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning,
CancellationToken cancellationToken)
{
string? resolvedEmail;
@@ -264,6 +297,7 @@ public sealed class TemplateRouteService(
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
_ => null
};
if (string.IsNullOrWhiteSpace(resolvedEmail))
@@ -286,13 +320,16 @@ public sealed class TemplateRouteService(
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
resolvedPosition,
step.AllowReassignToOtherApprovers,
warning,
combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason);
}
@@ -328,6 +365,41 @@ public sealed class TemplateRouteService(
private static bool TryParseNumber(string? value, out double number) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
/// <summary>Поле заявки, из которого вычисляется <see cref="ManagerConditionField"/>.</summary>
private const string PositionField = "Position";
/// <summary>Синтетическое поле условия шага — вычисляется, а не приходит из заявки.</summary>
private const string ManagerConditionField = "IsManager";
private const string PositionMissingWarning =
"В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " +
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
/// <summary>
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
/// предупреждением — см. ResolveAsync.
/// </summary>
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
positionMissing &&
string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) &&
step.Operation is not (null or ComparisonOperator.None);
/// <summary>
/// Должность — свободный текст без структуры (нет отдельного флага «это
/// руководитель»), поэтому определяем это по ключевым словам в её названии.
/// Список подобран по фактическим должностям в справочнике api.tnsu.kz —
/// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом.
/// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший
/// специалист, а не должность с подчинёнными.
/// </summary>
private static readonly string[] ManagerPositionKeywords =
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];
private static bool IsManagerPosition(string? position) =>
!string.IsNullOrWhiteSpace(position) &&
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
{
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -6,6 +6,7 @@ public enum HeadType
Chief,
DirectionHead,
DirectorTansu,
ManagingDirectorTansu,
/// <summary>
/// Руководитель отдела, указанного явно в запросе на resolve (см.
@@ -29,11 +29,17 @@ public static class InfrastructureServiceCollectionExtensions
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>();
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
// ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут,
// см. её комментарий) — сам HttpTnsuPortalClient регистрируем по
// конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить
// как обычную зависимость.
services.AddHttpClient<HttpTnsuPortalClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
services.AddMemoryCache();
services.AddScoped<ITnsuPortalClient, CachingTnsuPortalClient>();
return services;
}
@@ -0,0 +1,63 @@
using Microsoft.Extensions.Caching.Memory;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Кеширующая обёртка над <see cref="HttpTnsuPortalClient"/>. Резолв одного
/// маршрута (TemplateRouteService.ResolveAsync) может дёргать справочник
/// api.tnsu.kz по многу раз за один запрос — один и тот же согласующий,
/// должность или руководитель нередко повторяется в разных шагах длинного
/// маршрута, — и без кеша распараллеливание самих шагов резолва не спасает от
/// нагрузки на api.tnsu.kz: он просто получает то же число запросов
/// одновременно вместо по очереди. Кеш процессный (<see cref="IMemoryCache"/>,
/// не распределённый — реплик API несколько, поэтому в них он не общий), TTL
/// фиксирован на <see cref="CacheDuration"/>.
/// </summary>
public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryCache cache) : ITnsuPortalClient
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"emp:email:{email.ToLowerInvariant()}",
() => inner.GetEmployeeByEmailAsync(email, cancellationToken));
public Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
() => inner.GetManagerAsync(email, managerType, cancellationToken));
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"projexec:{projectId}",
() => inner.GetProjectExecutorsAsync(projectId, cancellationToken));
public Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"depthead:{departmentId}",
() => inner.GetDepartmentHeadEmailAsync(departmentId, cancellationToken));
/// <summary>
/// Кеширует и «не нашли» (null / пустой список) — повторять запрос к
/// api.tnsu.kz за тем же результатом на каждый одинаковый шаг маршрута
/// незачем. Ключ по одному и тому же значению может задеть гонку: два
/// конкурентных шага (см. Task.WhenAll в ResolveAsync) с одинаковым ключом
/// оба не найдут его в кеше и оба сходят в API — не страшно, это лишний
/// один поход, а не порча данных; блокировка per-key усложнила бы код ради
/// выигрыша, который при TTL в минуты и так почти всегда есть без неё.
/// </summary>
private async Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory)
{
if (cache.TryGetValue(key, out T? cached))
return cached!;
var value = await factory();
cache.Set(key, value, CacheDuration);
return value;
}
}
@@ -8,4 +8,5 @@ public sealed class TnsuPortalOptions
public string ClientSecret { get; set; } = string.Empty;
public string Scope { get; set; } = string.Empty;
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
public string ManagingDirectorTansuEmail { get; set; } = "nkim@tnsu.kz";
}
@@ -13,9 +13,11 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки
/// (колонка Date — request-portal проставляет её в момент, когда строка
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
/// и проверяют вместо прежнего вывода даты из соседних строк.
///
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
@@ -53,13 +55,12 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
private static object Row(
int id, int ticketId, int stepOrder, string status, string phase,
string? date = null, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
int id, int ticketId, string status, string phase,
string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
new
{
Id = id,
TicketId = ticketId,
StepOrder = stepOrder,
ApprovalStatus = status,
Phase = phase,
Date = date,
@@ -68,8 +69,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
TansuSiteApprover = new { EMail = email }
};
private ReminderService BuildService(
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
// мок отвечает так же: по значению статуса, а не по признаку решённости.
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
// а не тихо проигнорирует лишний запрос.
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
{
var sharePoint = new Mock<ISharePointClient>();
@@ -77,9 +83,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
// отвечает так же: по значению статуса, а не по признаку решённости.
var activeRows = allRows
var activeRows = rows
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
"На согласовании" or "На исполнении")
.ToList();
@@ -88,15 +92,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
.ReturnsAsync(activeRows.Select(Json).ToList());
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
.ReturnsAsync(allRows.Select(Json).ToList());
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
return new ReminderService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
@@ -104,14 +99,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
NullLogger<ReminderService>.Instance);
}
[Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")]
public async Task FirstApprovalStep_CountsFromRequestCreated()
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
public async Task ActiveRow_CountsFromOwnDate()
{
var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24);
// Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
var service = BuildService(listUrl,
[Row(10, 1, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-48));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -127,70 +121,43 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-2));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")]
public async Task SecondStep_CountsFromPreviousStepDecision()
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
public async Task OtherRoutRows_AreIrrelevant()
{
var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10);
// Заявка создана давно, но предыдущий шаг решён 12 часов назад:
// при отсчёте от создания просрочка была бы огромной, верная — 2 часа.
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
// сервис их дату уже не использует: отсчёт идёт только от Date
// активной строки.
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));
Row(10, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
Row(11, 1, "На согласовании", "Согласование", date: Now.AddHours(-12).ToString("O"))
]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
}
[Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")]
public async Task FirstExecutionStep_CountsFromLastApprovalDecision()
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
public async Task ActiveRow_WithoutDate_IsSkipped()
{
var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5);
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
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));
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
var reminder = result.Value.ShouldHaveSingleItem();
reminder.Email.ShouldBe("executor@tnsu.kz");
reminder.HoursOverdue.ShouldBe(3);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
@@ -199,8 +166,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -220,8 +186,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
// воспроизводит.
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)],
requestCreated: Now.AddHours(-6));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -233,8 +198,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
var service = BuildService(listUrl,
[Row(10, 1, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-6));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -249,8 +213,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
var service = BuildService(listUrl,
[Row(10, 11, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-6));
[Row(10, 11, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -264,11 +227,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
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));
[Row(10, 1, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -281,10 +240,9 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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));
Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O")),
Row(11, 1, "Ждёт очереди", "Согласование", date: null, email: "waiting@tnsu.kz")
]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);