diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs index 27f8132..638e23c 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs @@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth; public static class AuthSchemes { - /// Локальная разработка и тесты — Entra не настроен (см. AuthSetup). - public const string Dev = "Dev"; - /// Токен пользователя (делегированный) — обычная работа админки. public const string Bearer = JwtBearerDefaults.AuthenticationScheme; diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs index 5d82424..4ff5509 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs @@ -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(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)!; - }); - } } diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs deleted file mode 100644 index 8086a82..0000000 --- a/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs +++ /dev/null @@ -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 options, - ILoggerFactory logger, - UrlEncoder encoder, - IConfiguration configuration) - : AuthenticationHandler(options, logger, encoder) -{ - protected override Task 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)); - } -} diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json index 5adccfc..52e99be 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json @@ -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" } diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 8062415..e01d0f1 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -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" } } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs index dacb884..cd5d224 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs @@ -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). diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 9c68e35..f29ff7c 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -23,10 +23,6 @@ public sealed class ReminderService( IOptions options, ILogger 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(); 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; } - /// - /// Момент, с которого шаг считается активным, — начало отсчёта SLA. - /// - /// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это - /// точечное обновление статуса, дату оно не пишет), поэтому момент выводится - /// из соседних строк: - /// • первый шаг согласования — дата создания заявки; - /// • первый шаг исполнения — последнее решение по фазе согласования - /// (фаза исполнения активируется только когда согласование пройдено целиком); - /// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы. - /// - /// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить - /// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг - /// активируется только после решения последнего из них. - /// - /// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не - /// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по - /// условию появления). - /// - private static DateTimeOffset? ActivatedAt( - ApprovalRow row, IReadOnlyList 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 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(); - } - /// /// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг: /// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно @@ -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> ReadRowsAsync( @@ -219,43 +169,6 @@ public sealed class ReminderService( return Parse(items); } - private async Task> ReadRowsByTicketsAsync( - string siteUrl, string listTitle, IReadOnlyList ticketIds, CancellationToken cancellationToken) - { - var rows = new List(); - 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; - } - - /// Даты создания заявок — начало отсчёта для первого шага согласования. - private async Task> ReadCreatedAsync( - string siteUrl, string listUrl, IReadOnlyList ids, CancellationToken cancellationToken) - { - var created = new Dictionary(); - - 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 Parse(IEnumerable items) => items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 3d210d4..aae5af9 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -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(); - 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); + /// + /// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про + /// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются + /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты + /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. + /// private async Task ResolveStepAsync( TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList 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); + /// Поле заявки, из которого вычисляется . + private const string PositionField = "Position"; + + /// Синтетическое поле условия шага — вычисляется, а не приходит из заявки. + private const string ManagerConditionField = "IsManager"; + + private const string PositionMissingWarning = + "В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " + + "поэтому шаг включён в маршрут, хотя условие могло и не выполниться."; + + /// + /// Условие шага завязано на IsManager, но вычислить его не из чего: должность + /// в заявке не передана. Такой шаг попадает в результат вопреки условию и с + /// предупреждением — см. ResolveAsync. + /// + private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) => + positionMissing && + string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) && + step.Operation is not (null or ComparisonOperator.None); + + /// + /// Должность — свободный текст без структуры (нет отдельного флага «это + /// руководитель»), поэтому определяем это по ключевым словам в её названии. + /// Список подобран по фактическим должностям в справочнике api.tnsu.kz — + /// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом. + /// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший + /// специалист, а не должность с подчинёнными. + /// + 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 CollapseByExecutorEmail(IReadOnlyList steps) { var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs index 13ae882..6dacd1d 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -6,6 +6,7 @@ public enum HeadType Chief, DirectionHead, DirectorTansu, + ManagingDirectorTansu, /// /// Руководитель отдела, указанного явно в запросе на resolve (см. diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index ca7ef6f..9b1ba29 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -29,11 +29,17 @@ public static class InfrastructureServiceCollectionExtensions services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); - services.AddHttpClient((sp, client) => + // ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут, + // см. её комментарий) — сам HttpTnsuPortalClient регистрируем по + // конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить + // как обычную зависимость. + services.AddHttpClient((sp, client) => { var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl); }); + services.AddMemoryCache(); + services.AddScoped(); return services; } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs new file mode 100644 index 0000000..6ddab54 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +/// +/// Кеширующая обёртка над . Резолв одного +/// маршрута (TemplateRouteService.ResolveAsync) может дёргать справочник +/// api.tnsu.kz по многу раз за один запрос — один и тот же согласующий, +/// должность или руководитель нередко повторяется в разных шагах длинного +/// маршрута, — и без кеша распараллеливание самих шагов резолва не спасает от +/// нагрузки на api.tnsu.kz: он просто получает то же число запросов +/// одновременно вместо по очереди. Кеш процессный (, +/// не распределённый — реплик API несколько, поэтому в них он не общий), TTL +/// фиксирован на . +/// +public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryCache cache) : ITnsuPortalClient +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + + public Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) => + GetOrAddAsync( + $"emp:email:{email.ToLowerInvariant()}", + () => inner.GetEmployeeByEmailAsync(email, cancellationToken)); + + public Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) => + GetOrAddAsync( + $"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}", + () => inner.GetEmployeeByPositionAsync(position, company, cancellationToken)); + + public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => + GetOrAddAsync( + $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", + () => inner.GetManagerAsync(email, managerType, cancellationToken)); + + public Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) => + GetOrAddAsync( + $"projexec:{projectId}", + () => inner.GetProjectExecutorsAsync(projectId, cancellationToken)); + + public Task GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) => + GetOrAddAsync( + $"depthead:{departmentId}", + () => inner.GetDepartmentHeadEmailAsync(departmentId, cancellationToken)); + + /// + /// Кеширует и «не нашли» (null / пустой список) — повторять запрос к + /// api.tnsu.kz за тем же результатом на каждый одинаковый шаг маршрута + /// незачем. Ключ по одному и тому же значению может задеть гонку: два + /// конкурентных шага (см. Task.WhenAll в ResolveAsync) с одинаковым ключом + /// оба не найдут его в кеше и оба сходят в API — не страшно, это лишний + /// один поход, а не порча данных; блокировка per-key усложнила бы код ради + /// выигрыша, который при TTL в минуты и так почти всегда есть без неё. + /// + private async Task GetOrAddAsync(string key, Func> factory) + { + if (cache.TryGetValue(key, out T? cached)) + return cached!; + + var value = await factory(); + cache.Set(key, value, CacheDuration); + return value; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs index a0adf35..b95b2f6 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs @@ -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"; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 3467c06..9d60089 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -13,9 +13,11 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// -/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA: -/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент -/// выводится из соседних строк — это самая хрупкая часть эндпоинта. +/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки +/// (колонка Date — request-portal проставляет её в момент, когда строка +/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше +/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты +/// и проверяют вместо прежнего вывода даты из соседних строк. /// /// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его /// возвращает REST, — так тесты заодно покрывают и разбор ответа. @@ -53,13 +55,12 @@ public sealed class ReminderServiceTests : IClassFixture 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 allRows, DateTimeOffset requestCreated) + // Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) — + // мок отвечает так же: по значению статуса, а не по признаку решённости. + // Никаких запросов ни по TicketId, ни к списку данных за Created сервис + // больше не делает — если он вдруг снова начнёт, немонастроенный мок + // (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой, + // а не тихо проигнорирует лишний запрос. + private ReminderService BuildService(string listUrl, IReadOnlyList rows) { var sharePoint = new Mock(); @@ -77,9 +83,7 @@ public sealed class ReminderServiceTests : IClassFixture x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .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(q => q.Contains("ApprovalStatus eq")), It.IsAny())) .ReturnsAsync(activeRows.Select(Json).ToList()); - sharePoint - .Setup(x => x.GetItemsByListTitleAsync( - SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("TicketId eq")), It.IsAny())) - .ReturnsAsync(allRows.Select(Json).ToList()); - - sharePoint - .Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny(), It.IsAny())) - .ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]); - return new ReminderService( _services.GetRequiredService(), sharePoint.Object, @@ -104,14 +99,13 @@ public sealed class ReminderServiceTests : IClassFixture.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 = React.createElement(ApprovalAdmin, { context: this.context, apiBaseUrl: this.properties.apiBaseUrl, - useDevAuth: this.properties.useDevAuth, - devUserEmail: this.properties.devUserEmail, aadResourceId: this.properties.aadResourceId, portalBaseUrl: this.properties.portalBaseUrl }); @@ -39,11 +31,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart { - if (props.useDevAuth) { - return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz'); - } const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId); return new AadApiHttpClient(aadClient, props.apiBaseUrl); } @@ -50,11 +47,8 @@ const ApprovalAdmin: React.FC = props => { return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]); + }, [props.apiBaseUrl, props.aadResourceId]); - // Справочники всегда идут через AAD, независимо от Dev-режима нашего API: - // api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email - // ему ничего не говорит. React.useEffect(() => { let cancelled = false; setDictionary(undefined); diff --git a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts index e01a6e5..fdf929a 100644 --- a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts +++ b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts @@ -3,8 +3,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; export interface IApprovalAdminProps { context: WebPartContext; apiBaseUrl: string; - useDevAuth: boolean; - devUserEmail: string; aadResourceId: string; portalBaseUrl: string; } diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 691b78b..9537c3a 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -37,7 +37,9 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint api.requestTypes.get(r.requestTypeId) .then(setSelectedRequestType) .catch(() => setSelectedRequestType(undefined)); - navigate({ tab: 'routes', routeId: r.id }); + // routesFilter несём с собой: возврат «к маршрутам» должен показать тот же + // отфильтрованный список, из которого ушли (см. onBack ниже). + navigate({ tab: 'routes', routeId: r.id, routesFilter: route.routesFilter }); }; // Открытие по прямой ссылке / обновление страницы / кнопка «назад»: в URL @@ -79,7 +81,12 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint {route.tab === 'dashboard' && } {route.tab === 'requestTypes' && } {route.tab === 'routes' && !route.routeId && ( - + )} {route.tab === 'routes' && route.routeId && selectedRoute && ( = ({ api, dictionary, sharePoint requestType={selectedRequestType} dictionary={dictionary} sharePoint={sharePoint} - onBack={() => navigate({ tab: 'routes' })} + onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })} /> )} {route.tab === 'resolve' && ( diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx new file mode 100644 index 0000000..6a8c002 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx @@ -0,0 +1,229 @@ +import * as React from 'react'; +import { + Company, + CreateTemplateRouteStepInput, + Department, + ProjectRole, + TemplateRoute, + TemplateRouteStep +} from '../../models'; +import { texts } from '../../texts'; +import { shortGuid } from '../../utils/guid'; +import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; +import { useRouteSteps } from '../../hooks/useRouteSteps'; +import { ApprovalRouteApi } from '../../services/api'; +import { ApiClientError } from '../../services/httpClient'; +import { Alert, Checkbox, Field, FormGrid, FormModal, SearchableSelect } from '../ui'; + +export interface IRouteCopyModalProps { + api: ApprovalRouteApi; + /** Маршрут-источник: тип заявки у копии тот же, меняются только «координаты». */ + source: TemplateRoute; + requestTypeName: string; + companies: Company[]; + departments: Department[]; + dictionaryLoading: boolean; + onCopied: () => void; + onClose: () => void; +} + +interface IFormState { + companyId: string; + departmentId: string; + byProject: boolean; + requiresControl: boolean; +} + +/** Тело для создания шага-копии: всё, кроме id/routeId, переносится как есть. */ +function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput { + return { + orderNo: step.orderNo, + executorEmail: step.executorEmail, + position: step.position, + projectRole: step.projectRole, + headType: step.headType, + status: step.status, + allowReassignToOtherApprovers: step.allowReassignToOtherApprovers, + fieldName: step.fieldName, + operation: step.operation, + value: step.value, + slaHours: step.slaHours, + askOverdueReason: step.askOverdueReason + }; +} + +/** + * Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки. + * + * Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см. + * HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому + * копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это + * до запроса, чтобы вместо 409 от бэкенда показать понятную причину. + * + * Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ + * УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы + * молча резолвился с недостающими согласующими. + */ +export const RouteCopyModal: React.FC = ({ + api, + source, + requestTypeName, + companies, + departments, + dictionaryLoading, + onCopied, + onClose +}) => { + const [form, setForm] = React.useState({ + companyId: source.companyId ?? '', + departmentId: source.departmentId ?? '', + byProject: source.byProject, + requiresControl: source.requiresControl + }); + const steps = useRouteSteps(api, source.id); + const submit = useAsyncSubmit(); + + const departmentsForSelectedCompany = React.useMemo( + () => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []), + [departments, form.companyId] + ); + + const sourceCompany = source.companyId + ? companies.find(c => c.id === source.companyId)?.name ?? shortGuid(source.companyId) + : texts.routeSteps.anyCompany; + const sourceDepartment = source.departmentId + ? departments.find(d => d.id === source.departmentId)?.name ?? shortGuid(source.departmentId) + : texts.routeSteps.anyDepartment; + + // Шаги с ролью в проекте бэкенд не примет в маршрут без признака «по проекту». + const projectRoleSteps = steps.items.filter(s => s.projectRole && s.projectRole !== ProjectRole.None).length; + const projectRoleBlocks = projectRoleSteps > 0 && !form.byProject; + + // Копия должна отличаться от источника: тип заявки у них общий, значит + // различие обязано быть в компании, отделе или признаке «по проекту». + const sameAsSource = + (form.companyId || '') === (source.companyId ?? '') && + (form.departmentId || '') === (source.departmentId ?? '') && + form.byProject === source.byProject; + + const submitForm = (): void => { + if (sameAsSource) { + submit.fail(texts.routes.validationCopySame); + return; + } + if (projectRoleBlocks) { + submit.fail(texts.routes.copyProjectRoleWarning(projectRoleSteps)); + return; + } + + const companyId = form.companyId.trim() ? form.companyId.trim() : null; + const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null; + + submit.run(async () => { + const created = await api.templateRoutes.create({ + requestTypeId: source.requestTypeId, + companyId, + departmentId, + byProject: form.byProject, + requiresControl: form.requiresControl + }); + + // Последовательно, а не Promise.all: при ошибке важно знать, на каком шаге + // сорвались, а параллельные запросы к тому же дают гонку по orderNo в логах. + try { + for (const step of [...steps.items].sort((a, b) => a.orderNo - b.orderNo)) { + await api.templateRouteSteps.create(created.id, toStepInput(step)).catch((e: ApiClientError) => { + throw new Error(texts.routes.copyStepFailed(step.orderNo, e.message)); + }); + } + } catch (stepError) { + // Откат: маршрут без части шагов резолвился бы молча и неверно. + try { + await api.templateRoutes.remove(created.id); + } catch { + throw new Error( + `${(stepError as Error).message} ${texts.routes.copyRollbackFailed(created.id)}` + ); + } + throw new Error(`${(stepError as Error).message} ${texts.routes.copyRolledBack}`); + } + + onCopied(); + onClose(); + }); + }; + + return ( + + {texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)} + + {steps.error && {steps.error}} + {!steps.error && steps.isLoading && {texts.routes.copyStepsLoading}} + {!steps.error && !steps.isLoading && ( + 0 ? 'ok' : 'warn'}> + {steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty} + + )} + {projectRoleBlocks && {texts.routes.copyProjectRoleWarning(projectRoleSteps)}} + + + + ({ value: c.id, label: c.name }))} + value={form.companyId} + loading={dictionaryLoading} + allowClear + clearLabel={texts.routes.companyClear} + placeholder={texts.routes.companyPlaceholder} + searchPlaceholder={texts.routes.companySearchPlaceholder} + onChange={companyId => + // Как и в форме маршрута: смена компании обнуляет отдел — прежний + // принадлежал другой компании. + setForm({ ...form, companyId, departmentId: '' }) + } + /> + + + ({ value: d.id, label: d.name }))} + value={form.departmentId} + loading={dictionaryLoading} + disabled={!form.companyId} + allowClear + clearLabel={texts.routes.departmentPlaceholder} + placeholder={texts.routes.departmentPlaceholder} + searchPlaceholder={texts.routes.departmentSearchPlaceholder} + emptyMessage={texts.routes.departmentEmpty} + onChange={departmentId => setForm({ ...form, departmentId })} + /> + + + setForm({ ...form, byProject: v })} + /> + + + setForm({ ...form, requiresControl: v })} + /> + + + + ); +}; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.module.scss b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.module.scss new file mode 100644 index 0000000..12a5ea2 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.module.scss @@ -0,0 +1,72 @@ +@import '../../../../styles/design-tokens'; + +// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри +// Field, поэтому отступ сверху небольшой — визуально это продолжение поля, +// а не отдельный блок. +.positionPreview { + margin-top: 6px; + padding: 8px 10px; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; + font-size: 12px; + line-height: 1.5; +} + +// Найдено — зелёная рамка: должность рабочая, шаг резолвится. +.previewOk { + border-color: $green-600; + background: rgba($green-600, .06); +} + +// Не найдено — предупреждение: шаг останется без исполнителя. +.previewMiss { + border-color: $gold-500; + background: $gold-50; +} + +.previewTitle { + color: $gray-700; +} + +.previewList { + margin: 6px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 3px; +} + +.previewPerson { + color: $gray-900; +} + +.previewCompany { + color: $gray-500; +} + +// Похожая должность — кнопка: клик подставляет её точное написание в поле, +// чтобы не переписывать вручную и не ошибиться в регистре/пробелах. +.previewSuggest { + display: block; + width: 100%; + padding: 4px 6px; + border: 0; + border-radius: $r-sm; + background: transparent; + text-align: left; + font-family: $font-sans; + font-size: 12px; + color: $navy-700; + cursor: pointer; + + &:hover { + background: #fff; + color: $gold-600; + } +} + +.previewSuggestCount { + color: $gray-500; +} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 43b1643..0d9bd2e 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import styles from './RouteStepFormModal.module.scss'; import { ComparisonOperator, Employee, @@ -12,7 +13,7 @@ import { } from '../../models'; import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { detectExecutorKind } from '../../utils/stepExecutor'; -import { toEmployeeOption } from '../../utils/employee'; +import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useSharePointFields } from '../../hooks/useSharePointFields'; import { ApprovalRouteApi } from '../../services/api'; @@ -31,6 +32,15 @@ import { export interface IRouteStepFormModalProps { api: ApprovalRouteApi; route: TemplateRoute; + /** + * Название компании маршрута, если она задана (route.companyId) — undefined + * для запасного маршрута («любая компания»). Резолв шага «по должности» + * ищет исполнителя ИМЕННО в этой компании (initiatorCompany = компания + * инициатора, а маршрут с заданной компанией применяется только к таким + * инициаторам), поэтому подсказка должности должна сужаться под неё же — + * иначе показала бы человека, которого этот маршрут никогда не найдёт. + */ + routeCompanyName?: string; /** Undefined, пока грузится тип заявки маршрута. */ requestType?: RequestType; sharePoint: SharePointListsClient; @@ -76,6 +86,20 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator) .filter(v => v !== ComparisonOperator.None) .map(v => ({ value: v, label: comparisonOperatorLabels[v] })); +/** + * Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде + * (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ + * («Должность» — например, у увольняемого в обходном листе), а не по должности того, + * кто отправил заявку. Поэтому поле доступно всегда, даже если у типа заявки не + * настроен list_name — источник данных не сам список, а fieldValues resolve-запроса. + */ +const IS_MANAGER_FIELD = 'IsManager'; +const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }]; +const IS_MANAGER_VALUE_OPTIONS = [ + { value: 'true', label: texts.common.yes }, + { value: 'false', label: texts.common.no } +]; + function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { if (!editing) { return { @@ -117,6 +141,7 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number export const RouteStepFormModal: React.FC = ({ api, route, + routeCompanyName, requestType, sharePoint, editing, @@ -132,18 +157,23 @@ export const RouteStepFormModal: React.FC = ({ const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); const fieldNameOptions = React.useMemo(() => { - const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); + const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); + const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions]; if (form.fieldName && !options.some(o => o.value === form.fieldName)) { - options.unshift({ value: form.fieldName, label: form.fieldName }); + options.push({ value: form.fieldName, label: form.fieldName }); } return options; }, [sharePointFields.items, form.fieldName]); + const isManagerField = form.fieldName === IS_MANAGER_FIELD; + // Тип столбца известен, только если он есть среди подгруженных полей списка — // для значения, введённого до появления поля в списке (или после его удаления), // ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные. + // IsManager — служебное булево поле, а не колонка списка, поэтому для него + // числовые ограничения не действуют в любом случае. const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName); - const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true; + const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true; const operationOptions = isNumericField ? OPERATION_OPTIONS @@ -158,6 +188,121 @@ export const RouteStepFormModal: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNumericField]); + // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, + // а не произвольным текстом, который пользователь мог набрать для другого field_name. + React.useEffect(() => { + if (isManagerField && form.value !== 'true' && form.value !== 'false') { + setForm(f => ({ ...f, value: 'true' })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isManagerField]); + + // Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ + // совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо + // оставляет шаг без исполнителя — до самого резолва об этом узнать негде. + // Считаем по уже загруженному справочнику сотрудников, без запросов на ввод. + const positionMatches = React.useMemo( + () => matchEmployeesByPosition(employees, form.position), + [employees, form.position] + ); + + const positionPreview = ((): React.ReactNode => { + if (!form.position.trim()) return undefined; + if (employeesLoading) { + return
{texts.routeSteps.positionPreviewLoading}
; + } + // Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это + // сказало бы неправду про корректную должность. + if (employees.length === 0) { + return
{texts.routeSteps.positionPreviewNoDictionary}
; + } + + if (positionMatches.exact.length > 0) { + // У маршрута задана конкретная компания — резолв ищет исполнителя ИМЕННО + // в ней (route.companyId сужает круг инициаторов до этой компании, а + // бэкенд ищет по компании инициатора). Найденные в других компаниях + // этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт. + const inRouteCompany = routeCompanyName + ? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1) + : positionMatches.exact; + + if (inRouteCompany.length > 0) { + return ( +
+ + {routeCompanyName + ? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length) + : texts.routeSteps.positionPreviewFound(inRouteCompany.length)} + +
    + {inRouteCompany.map(e => { + // Показываем ИМЕННО ту компанию, по которой сотрудник совпал с + // маршрутом — она не обязана быть его company (первой из + // справочника), если он числится в нескольких (см. companies). + const shownCompany = routeCompanyName ?? e.company; + return ( +
  • + {e.fio} + {shownCompany && · {shownCompany}} +
  • + ); + })} +
+
+ ); + } + + // routeCompanyName задан, но среди найденных нет никого из этой компании — + // должность существует, но не там, где её будет искать этот маршрут. + return ( +
+ + {texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)} + +
{texts.routeSteps.positionPreviewOtherCompanies}
+
    + {positionMatches.exact.map(e => ( +
  • + {e.fio} + {e.company && · {e.company}} +
  • + ))} +
+
+ ); + } + + return ( +
+ {texts.routeSteps.positionPreviewNone} + {positionMatches.similar.length > 0 && ( + <> +
{texts.routeSteps.positionPreviewSimilar}
+
    + {positionMatches.similar.map(s => ( +
  • + +
  • + ))} +
+ + )} +
+ ); + })(); + const submitForm = (): void => { const orderNo = Number(form.orderNo); if (!orderNo || orderNo < 1) { @@ -276,6 +421,7 @@ export const RouteStepFormModal: React.FC = ({ onChange={e => setForm({ ...form, position: e.target.value })} placeholder={texts.routeSteps.positionPlaceholder} /> + {positionPreview} )} {form.kind === 'projectRole' && ( @@ -326,12 +472,11 @@ export const RouteStepFormModal: React.FC = ({ setForm({ ...form, hasCondition: v })} /> - {form.hasCondition && hasListId && ( + {form.hasCondition && ( <> = ({ /> - setForm({ ...form, value: e.target.value })} - placeholder={texts.routeSteps.conditionValuePlaceholder} - /> + {isManagerField ? ( +