Обходной лист #12

Merged
gorbenko.pavel merged 11 commits from clearance-form into main 2026-08-17 05:43:52 +00:00
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);
@@ -1,11 +1,7 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
type IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneToggle
} from '@microsoft/sp-property-pane';
import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import ApprovalAdmin from './components/ApprovalAdmin';
@@ -13,8 +9,6 @@ import { IApprovalAdminProps } from './components/IApprovalAdminProps';
export interface IApprovalAdminWebPartProps {
apiBaseUrl: string;
useDevAuth: boolean;
devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
@@ -25,8 +19,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
const element: React.ReactElement<IApprovalAdminProps> = 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<IApprova
// Для локальной разработки переопределите значение в панели свойств веб-части
// (например, на http://localhost:5080 — порт из backend/.../launchSettings.json).
this.properties.apiBaseUrl = this.properties.apiBaseUrl || 'https://api.tnsu.kz/approval-route';
// Dev-режим ломает /resolve: наш бэкенд не получает Bearer-токен пользователя
// для пересылки в api.tnsu.kz и падает на собственном client_credentials
// (TnsuPortal:ClientSecret в appsettings.json пуст). По умолчанию — реальный AAD.
this.properties.useDevAuth = this.properties.useDevAuth ?? false;
this.properties.devUserEmail = this.properties.devUserEmail || 'dev@tnsu.kz';
this.properties.aadResourceId = this.properties.aadResourceId || 'api://9232d9e2-9807-4753-808f-07250e5474b0';
this.properties.portalBaseUrl = this.properties.portalBaseUrl || 'https://api.tnsu.kz';
return Promise.resolve();
@@ -72,15 +59,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart<IApprova
label: 'Api Base Url',
description: 'По умолчанию — прод (https://api.tnsu.kz/approval-route). Для локальной разработки укажите http://localhost:5080.'
}),
PropertyPaneToggle('useDevAuth', {
label: 'Локальный Dev-режим (без Azure AD)',
onText: 'Вкл. (заголовок X-Dev-User-Email)',
offText: 'Выкл. (AAD-токен через SPFx)'
}),
PropertyPaneTextField('devUserEmail', {
label: 'Dev: email пользователя',
description: 'Используется только при включённом Dev-режиме.'
}),
PropertyPaneTextField('aadResourceId', {
label: 'AAD Resource Id (App ID URI)',
description: 'Должен совпадать с webApiPermissionRequests в package-solution.json. Этим же токеном берутся справочники api.tnsu.kz.'
@@ -3,7 +3,7 @@ import styles from './ApprovalAdmin.module.scss';
import type { IApprovalAdminProps } from './IApprovalAdminProps';
import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { AadApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists';
import { RequestManageService } from '../services/requestManageService';
@@ -21,9 +21,6 @@ function ensureFontsLoaded(): void {
}
async function createHttpClient(props: IApprovalAdminProps): Promise<IHttpClient> {
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<IApprovalAdminProps> = 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);
@@ -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;
}
@@ -37,7 +37,9 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ 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<IAdminAppProps> = ({ api, dictionary, sharePoint
{route.tab === 'dashboard' && <DashboardScreen api={api} onNavigate={navigateTab} />}
{route.tab === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{route.tab === 'routes' && !route.routeId && (
<RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />
<RoutesScreen
api={api}
dictionary={dictionary}
initialFilterRequestTypeId={route.routesFilter}
onOpenSteps={openSteps}
/>
)}
{route.tab === 'routes' && route.routeId && selectedRoute && (
<RouteStepsScreen
@@ -88,7 +95,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
requestType={selectedRequestType}
dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => navigate({ tab: 'routes' })}
onBack={() => navigate({ tab: 'routes', routesFilter: route.routesFilter })}
/>
)}
{route.tab === 'resolve' && (
@@ -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<IRouteCopyModalProps> = ({
api,
source,
requestTypeName,
companies,
departments,
dictionaryLoading,
onCopied,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>({
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 (
<FormModal
title={texts.routes.copyTitle(source.id)}
error={submit.error}
isSaving={submit.isBusy}
submitLabel={texts.routes.copyAction}
savingLabel={texts.routes.copyInProgress}
onSubmit={submitForm}
onClose={onClose}
>
<Alert kind="ok">{texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)}</Alert>
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{!steps.error && steps.isLoading && <Alert kind="ok">{texts.routes.copyStepsLoading}</Alert>}
{!steps.error && !steps.isLoading && (
<Alert kind={steps.items.length > 0 ? 'ok' : 'warn'}>
{steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty}
</Alert>
)}
{projectRoleBlocks && <Alert kind="warn">{texts.routes.copyProjectRoleWarning(projectRoleSteps)}</Alert>}
<FormGrid>
<Field label={texts.routes.companyLabel} full hint={texts.routes.copyHint}>
<SearchableSelect
options={companies.map(c => ({ 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: '' })
}
/>
</Field>
<Field
label={texts.routes.departmentLabel}
full
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
>
<SearchableSelect
options={departmentsForSelectedCompany.map(d => ({ 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 })}
/>
</Field>
<Field label={texts.routes.byProjectLabel}>
<Checkbox
label={texts.routes.byProjectCheckbox}
checked={form.byProject}
onChange={v => setForm({ ...form, byProject: v })}
/>
</Field>
<Field label={texts.routes.controlLabel}>
<Checkbox
label={texts.routes.controlCheckbox}
checked={form.requiresControl}
onChange={v => setForm({ ...form, requiresControl: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};
@@ -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;
}
@@ -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<IRouteStepFormModalProps> = ({
api,
route,
routeCompanyName,
requestType,
sharePoint,
editing,
@@ -132,18 +157,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
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<IRouteStepFormModalProps> = ({
// 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 <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewLoading}</div>;
}
// Справочник не приехал (нет клиента/ошибка) — молчим о «не найдено»: это
// сказало бы неправду про корректную должность.
if (employees.length === 0) {
return <div className={styles.positionPreview}>{texts.routeSteps.positionPreviewNoDictionary}</div>;
}
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 (
<div className={`${styles.positionPreview} ${styles.previewOk}`}>
<span className={styles.previewTitle}>
{routeCompanyName
? texts.routeSteps.positionPreviewFoundInCompany(routeCompanyName, inRouteCompany.length)
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
</span>
<ul className={styles.previewList}>
{inRouteCompany.map(e => {
// Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
// маршрутом — она не обязана быть его company (первой из
// справочника), если он числится в нескольких (см. companies).
const shownCompany = routeCompanyName ?? e.company;
return (
<li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span>
{shownCompany && <span className={styles.previewCompany}> · {shownCompany}</span>}
</li>
);
})}
</ul>
</div>
);
}
// routeCompanyName задан, но среди найденных нет никого из этой компании —
// должность существует, но не там, где её будет искать этот маршрут.
return (
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
<span className={styles.previewTitle}>
{texts.routeSteps.positionPreviewWrongCompany(routeCompanyName as string)}
</span>
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewOtherCompanies}</div>
<ul className={styles.previewList}>
{positionMatches.exact.map(e => (
<li key={e.email}>
<span className={styles.previewPerson}>{e.fio}</span>
{e.company && <span className={styles.previewCompany}> · {e.company}</span>}
</li>
))}
</ul>
</div>
);
}
return (
<div className={`${styles.positionPreview} ${styles.previewMiss}`}>
<span className={styles.previewTitle}>{texts.routeSteps.positionPreviewNone}</span>
{positionMatches.similar.length > 0 && (
<>
<div className={styles.previewTitle}>{texts.routeSteps.positionPreviewSimilar}</div>
<ul className={styles.previewList}>
{positionMatches.similar.map(s => (
<li key={s.position}>
<button
type="button"
className={styles.previewSuggest}
onClick={() => setForm(f => ({ ...f, position: s.position }))}
>
{s.position} — {s.fio}
{s.count > 1 && (
<span className={styles.previewSuggestCount}>
{' '}
({texts.routeSteps.positionPreviewMore(s.count - 1)})
</span>
)}
</button>
</li>
))}
</ul>
</>
)}
</div>
);
})();
const submitForm = (): void => {
const orderNo = Number(form.orderNo);
if (!orderNo || orderNo < 1) {
@@ -276,6 +421,7 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={e => setForm({ ...form, position: e.target.value })}
placeholder={texts.routeSteps.positionPlaceholder}
/>
{positionPreview}
</Field>
)}
{form.kind === 'projectRole' && (
@@ -326,12 +472,11 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
<Checkbox
label={texts.routeSteps.conditionCheckbox}
checked={form.hasCondition}
disabled={!hasListId}
onChange={v => setForm({ ...form, hasCondition: v })}
/>
</Field>
{form.hasCondition && hasListId && (
{form.hasCondition && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
@@ -361,12 +506,20 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
{isManagerField ? (
<Select
options={IS_MANAGER_VALUE_OPTIONS}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
/>
) : (
<TextInput
type={isNumericField ? 'number' : 'text'}
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
)}
</Field>
</>
)}
@@ -0,0 +1,83 @@
@import '../../../../styles/design-tokens';
// Таблица шагов маршрута собрана вручную (а не через DataTable) из-за rowSpan:
// колонка «№» и стрелки перестановки объединяются на весь блок параллельных
// шагов. Стили повторяют ManageRequestsScreen.module.scss — там та же механика
// для строк заявки (см. ManageRequestsPhaseTable).
.stepCol {
width: 56px;
white-space: nowrap;
text-align: center !important;
}
// Колонка «№» закреплена слева при горизонтальном скролле — на узких экранах/
// в редакторе SharePoint таблица шире контейнера, и без этого номер шага
// (единственный ориентир при длинном горизонтальном скролле) уезжал за край
// вместе с остальными колонками. Зеркало правого .stickyCell в
// DataTable.module.scss (там — action-колонка у правого края).
.stepColSticky {
position: sticky;
left: 0;
background: #fff;
box-shadow: 6px 0 6px -6px rgba(15, 36, 64, .12);
}
.table thead th.stepColSticky {
background: $gray-50;
z-index: 2;
}
.table tbody td.stepColSticky {
z-index: 1;
}
// Явно, а не полагаясь на специфичность: .rowHover td и .stepColSticky иначе
// шли бы вровень, и было бы неочевидно, какой фон победит при следующей правке.
.rowHover td.stepColSticky {
background: $gold-50;
}
// Шаг, идущий одновременно с соседним (общий orderNo) — помечаем знаком ∥,
// иначе два одинаковых номера подряд выглядят как ошибка данных.
.parallelMark {
color: $gold-600;
font-weight: 600;
}
.flipY {
svg { transform: scaleY(-1); }
}
// Штатную подсветку строки отключаем: ячейки с rowSpan принадлежат <tr> только
// первой строки блока, поэтому CSS :hover оставлял «вырез» в подсветке на
// границе объединённой ячейки. Подсвечиваем блок целиком через React-состояние.
.blockAwareWrap :global(tbody tr:hover) {
background: transparent;
}
.rowHover td {
background: $gold-50;
}
// IconButton по умолчанию прозрачная (рамка только по hover). Здесь кнопок
// много и часть неочевидна по смыслу (параллельность, перестановка) — рамка
// нужна всегда, иначе выглядит как текст. !important: это разные CSS-модули,
// порядок их правил в бандле не гарантирован.
.iconBtnVisible {
border-color: $gray-300 !important;
background: #fff !important;
}
// «Объединить с предыдущим» — переключатель состояния, а не разовое действие,
// поэтому включённое состояние залито.
.iconBtnActive {
border-color: $gold-500 !important;
background: $gold-500 !important;
color: $navy-950 !important;
&:hover:not(:disabled) {
border-color: $gold-600 !important;
background: $gold-600 !important;
}
}
@@ -1,14 +1,20 @@
import * as React from 'react';
import tableStyles from '../ui/DataTable.module.scss';
import styles from './RouteStepsScreen.module.scss';
import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { describeStepCondition, describeStepExecutor } from '../../utils/stepExecutor';
import { blockIndexOf, blockRanges, markParallel, withOrdersFrom } from '../../utils/orderBlocks';
import { reportError } from '../../utils/errors';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import {
ActionsCell,
Alert,
@@ -17,7 +23,6 @@ import {
Button,
CellNote,
ConfirmDialog,
DataTable,
IconButton,
PageHeader,
PrimaryCell
@@ -34,10 +39,17 @@ export interface IRouteStepsScreenProps {
onBack: () => void;
}
/** Шаг + признак «идёт одновременно с предыдущим» (выводится из равного orderNo). */
type Row = TemplateRouteStep & { order: number; parallelWithPrev?: boolean };
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary);
const portalDictionaries = usePortalDictionaries(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const [reorderError, setReorderError] = React.useState<string | undefined>();
const [reordering, setReordering] = React.useState(false);
const [hoveredBlock, setHoveredBlock] = React.useState<number | undefined>(undefined);
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
@@ -48,6 +60,15 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
steps.reload
);
// Пока справочник не загрузился (или маршрут ссылается на удалённую запись),
// показываем усечённый Guid — как в RoutesScreen.
const companyName = route.companyId
? portalDictionaries.companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
: texts.routeSteps.anyCompany;
const departmentName = route.departmentId
? portalDictionaries.departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
: texts.routeSteps.anyDepartment;
const openCreate = (): void => {
setEditing(undefined);
setShowForm(true);
@@ -63,6 +84,56 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
steps.reload();
};
// Параллельность у шагов шаблона кодируется тем же способом, что и у строк
// заявки: равный порядковый номер у соседей (отдельного флага в БД нет —
// см. utils/orderBlocks.ts и TemplateRouteStep.orderNo). markParallel/blockRanges
// работают по полю order, поэтому orderNo продублирован в него.
const withParallel = React.useMemo(
() => markParallel(steps.items.map(s => ({ ...s, order: s.orderNo }))) as Row[],
[steps.items]
);
const blocks = React.useMemo(() => blockRanges(withParallel), [withParallel]);
// Перестановка = пересчёт orderNo у затронутых шагов. Патчим только те, у кого
// номер реально изменился: PATCH с одним orderNo сохраняет остальные поля
// (см. PatchAsync на бэкенде — там `r.X ?? entity.X`).
const applyReorder = (mutate: (rows: Row[]) => Row[]): void => {
const renumbered = withOrdersFrom(mutate(withParallel), 0) as Row[];
const updates = renumbered.filter(r => r.orderNo !== r.order).map(r => ({ id: r.id, orderNo: r.order }));
if (updates.length === 0) return;
setReorderError(undefined);
setReordering(true);
Promise.all(updates.map(u => api.templateRouteSteps.patch(route.id, u.id, { orderNo: u.orderNo })))
.then(() => steps.reload())
.catch((e: Error) => setReorderError(texts.routeSteps.reorderFailed(reportError(e, 'RouteStepsScreen: reorder'))))
.then(() => setReordering(false), () => setReordering(false));
};
const moveBlockUp = (blockIndex: number): void => {
if (blockIndex <= 0) return;
applyReorder(current => {
const a = blocks[blockIndex - 1];
const b = blocks[blockIndex];
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
});
};
const moveBlockDown = (blockIndex: number): void => {
if (blockIndex >= blocks.length - 1) return;
applyReorder(current => {
const a = blocks[blockIndex];
const b = blocks[blockIndex + 1];
return [...current.slice(0, a.start), ...current.slice(b.start, b.end + 1), ...current.slice(a.start, a.end + 1), ...current.slice(b.end + 1)];
});
};
// Первая строка списка объединяться не с чем — у неё переключатель недоступен.
const toggleParallel = (rowIndex: number): void => {
if (rowIndex <= 0) return;
applyReorder(current => current.map((r, i) => (i === rowIndex ? { ...r, parallelWithPrev: !r.parallelWithPrev } : r)));
};
const nextOrderNo = steps.items.length > 0 ? Math.max(...steps.items.map(s => s.orderNo)) + 1 : 1;
return (
@@ -76,7 +147,7 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
/>
<PageHeader
icon="flag-check"
subtitle={texts.routeSteps.subtitle(requestTypeName)}
subtitle={texts.routeSteps.subtitle(requestTypeName, companyName, departmentName)}
title={texts.routeSteps.title(route.id)}
actions={
<>
@@ -88,65 +159,131 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
{savedWarning && <Alert kind="warn">{savedWarning}</Alert>}
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{reorderError && <Alert kind="err">{reorderError}</Alert>}
{deleteTarget.error && <Alert kind="err">{deleteTarget.error}</Alert>}
<DataTable
columns={[
{ key: 'order', header: texts.routeSteps.columnOrderNo, width: '50px', render: r => r.orderNo },
{
key: 'executor',
header: texts.routeSteps.columnExecutor,
render: r => {
const executor = describeStepExecutor(r, employees.items);
return (
<div>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</div>
);
}
},
{
key: 'status',
header: texts.routeSteps.columnStatus,
render: r => (
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
)
},
{
key: 'reassign',
header: texts.routeSteps.columnReassign,
align: 'center',
render: r => (r.allowReassignToOtherApprovers ? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge> : <Badge variant="neutral">{texts.common.no}</Badge>)
},
{
key: 'condition',
header: texts.routeSteps.columnCondition,
render: r => describeStepCondition(r) ?? texts.common.empty
},
{
key: 'actions',
header: '',
width: '100px',
align: 'right',
sticky: true,
render: r => (
<ActionsCell>
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
)
}
]}
rows={steps.items}
keyOf={r => r.id}
emptyMessage={steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}
/>
{steps.items.length === 0 && (
<div className={tableStyles.wrap}>
<div className={tableStyles.empty}>{steps.isLoading ? texts.common.loading : texts.routeSteps.emptyList}</div>
</div>
)}
{steps.items.length > 0 && (
<div className={`${tableStyles.wrap} ${styles.blockAwareWrap}`}>
<div className={tableStyles.scroll}>
<table className={tableStyles.table}>
<thead>
<tr>
<th className={`${styles.stepCol} ${styles.stepColSticky}`}>{texts.routeSteps.columnOrderNo}</th>
<th>{texts.routeSteps.columnExecutor}</th>
<th>{texts.routeSteps.columnStatus}</th>
<th style={{ textAlign: 'center' }}>{texts.routeSteps.columnReassign}</th>
<th>{texts.routeSteps.columnCondition}</th>
<th />
<th />
</tr>
</thead>
<tbody>
{withParallel.map((r, i) => {
const blockIndex = blockIndexOf(blocks, i);
const block = blocks[blockIndex];
const isBlockStart = block.start === i;
const executor = describeStepExecutor(r, employees.items);
return (
<tr
key={r.id}
className={blockIndex === hoveredBlock ? styles.rowHover : undefined}
onMouseEnter={() => setHoveredBlock(blockIndex)}
onMouseLeave={() => setHoveredBlock(undefined)}
>
{isBlockStart && (
<td className={`${styles.stepCol} ${styles.stepColSticky}`} rowSpan={block.end - block.start + 1}>
{block.end > block.start ? (
<span className={styles.parallelMark} title={texts.routeSteps.mergeWithPrev}>
{r.order} ∥
</span>
) : (
r.order
)}
</td>
)}
<td>
<PrimaryCell>{executor.primary}</PrimaryCell>
{executor.secondary && <CellNote>{executor.secondary}</CellNote>}
</td>
<td>
<Badge variant={r.status === RouteStepStatus.Approval ? 'neutral' : 'blue'}>
{routeStepStatusLabels[r.status]}
</Badge>
</td>
<td style={{ textAlign: 'center' }}>
{r.allowReassignToOtherApprovers
? <Badge variant="ok">{texts.routeSteps.reassignAllowed}</Badge>
: <Badge variant="neutral">{texts.common.no}</Badge>}
</td>
<td>{describeStepCondition(r) ?? texts.common.empty}</td>
{/* Стрелки — одна пара на весь блок (rowSpan): двигают шаг
целиком, а не отдельного участника параллельной группы. */}
{isBlockStart && (
<td rowSpan={block.end - block.start + 1}>
<ActionsCell>
<IconButton
icon="chevron-down"
className={`${styles.iconBtnVisible} ${styles.flipY}`}
label={texts.routeSteps.moveUp}
disabled={reordering || blockIndex <= 0}
onClick={() => moveBlockUp(blockIndex)}
/>
<IconButton
icon="chevron-down"
className={styles.iconBtnVisible}
label={texts.routeSteps.moveDown}
disabled={reordering || blockIndex >= blocks.length - 1}
onClick={() => moveBlockDown(blockIndex)}
/>
</ActionsCell>
</td>
)}
<td>
<ActionsCell>
<IconButton
icon="people"
className={r.parallelWithPrev ? styles.iconBtnActive : styles.iconBtnVisible}
label={r.parallelWithPrev ? texts.routeSteps.splitFromPrev : texts.routeSteps.mergeWithPrev}
disabled={reordering || i === 0}
onClick={() => toggleParallel(i)}
/>
<IconButton
icon="edit"
className={styles.iconBtnVisible}
label={texts.common.edit}
onClick={() => openEdit(r)}
/>
<IconButton
icon="trash"
className={styles.iconBtnVisible}
label={texts.common.remove}
danger
onClick={() => deleteTarget.ask(r)}
/>
</ActionsCell>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{showForm && (
<RouteStepFormModal
// Undefined, если у маршрута компания не задана (запасной маршрут,
// применяется к любой компании инициатора) — тогда подсказка
// должности показывает всех, как раньше, а не сужает по компании.
routeCompanyName={route.companyId ? companyName : undefined}
api={api}
route={route}
requestType={requestType}
@@ -1,6 +1,7 @@
import * as React from 'react';
import styles from './RoutesScreen.module.scss';
import { RouteFormModal } from './RouteFormModal';
import { RouteCopyModal } from './RouteCopyModal';
import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { TemplateRoute } from '../../models';
@@ -10,6 +11,8 @@ import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useTemplateRoutes } from '../../hooks/useTemplateRoutes';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { replaceRoute } from '../../routing';
import {
ActionsCell,
Alert,
@@ -28,18 +31,46 @@ import {
export interface IRoutesScreenProps {
api: ApprovalRouteApi;
dictionary?: PortalDictionaryClient;
/** Фильтр из ссылки (aarfilter) — перекрывает сохранённый, если задан. */
initialFilterRequestTypeId?: string;
onOpenSteps: (route: TemplateRoute) => void;
}
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, onOpenSteps }) => {
// Фильтр по типу заявки переживает перезагрузку: администратор обычно работает
// с одним типом заявок подряд, и сбрасывать выбор на каждый заход неудобно.
const ROUTES_FILTER_STORAGE_KEY = 'approvalAdmin.routesFilter';
export const RoutesScreen: React.FC<IRoutesScreenProps> = ({
api,
dictionary,
initialFilterRequestTypeId,
onOpenSteps
}) => {
const requestTypes = useRequestTypes(api);
const portalDictionaries = usePortalDictionaries(dictionary);
const [filterRequestTypeId, setFilterRequestTypeId] = React.useState('');
const [filterRequestTypeId, setFilterRequestTypeId] = useLocalStorageState(ROUTES_FILTER_STORAGE_KEY, '');
// Открыли по ссылке с фильтром — она сильнее сохранённого значения. Только на
// монтировании: дальше источник истины — сам экран (он же пишет фильтр в URL
// ниже), иначе собственный replaceRoute тут же вернулся бы сюда как «внешний».
React.useEffect(() => {
if (initialFilterRequestTypeId) setFilterRequestTypeId(initialFilterRequestTypeId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Держим URL синхронным с фильтром — ссылку на отфильтрованный список можно
// скопировать. replaceRoute, а не navigate: смена фильтра не должна плодить
// записи в истории браузера.
React.useEffect(() => {
replaceRoute({ tab: 'routes', routesFilter: filterRequestTypeId });
}, [filterRequestTypeId]);
const routes = useTemplateRoutes(api, filterRequestTypeId ? Number(filterRequestTypeId) : undefined);
const [editing, setEditing] = React.useState<TemplateRoute | undefined>();
const [showForm, setShowForm] = React.useState(false);
const [copying, setCopying] = React.useState<TemplateRoute | undefined>();
const deleteTarget = useDeleteTarget<TemplateRoute>(item => api.templateRoutes.remove(item.id), routes.reload);
@@ -87,6 +118,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
<DataTable
columns={[
{ key: 'id', header: texts.routes.columnId, width: '90px', render: r => `#${r.id}` },
{
key: 'requestType',
header: texts.routes.columnRequestType,
@@ -115,6 +147,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
render: r => (
<ActionsCell>
<Button size="sm" variant="secondary" icon="clipboard" onClick={() => onOpenSteps(r)}>{texts.routes.openSteps}</Button>
<IconButton icon="copy" label={texts.routes.copy} onClick={() => setCopying(r)} />
<IconButton icon="edit" label={texts.common.edit} onClick={() => openEdit(r)} />
<IconButton icon="trash" label={texts.common.remove} danger onClick={() => deleteTarget.ask(r)} />
</ActionsCell>
@@ -140,6 +173,19 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
/>
)}
{copying && (
<RouteCopyModal
api={api}
source={copying}
requestTypeName={requestTypeName(copying.requestTypeId)}
companies={portalDictionaries.companies}
departments={portalDictionaries.departments}
dictionaryLoading={portalDictionaries.isLoading}
onCopied={routes.reload}
onClose={() => setCopying(undefined)}
/>
)}
{deleteTarget.target && (
<ConfirmDialog
title={texts.routes.deleteTitle}
@@ -18,10 +18,17 @@
.full { grid-column: 1 / -1; }
.label {
// display:block + min-height на 2 строки — так подпись из одной строки
// и подпись, свернувшаяся на две (например «Спрашивать причину
// просрочки»), одинаково резервируют место, и контролы двух полей одной
// строки грида начинаются на одном уровне независимо от переноса текста.
display: block;
font-size: 12px;
font-weight: 500;
color: $gray-700;
letter-spacing: .2px;
line-height: 16px;
min-height: 32px;
}
.req { color: $gold-600; }
@@ -9,6 +9,10 @@ export interface IFormModalProps {
/** Текст ошибки: и валидации формы, и ответа бэкенда. */
error?: string;
isSaving: boolean;
/** Подпись кнопки подтверждения — по умолчанию «Сохранить» (см. копирование маршрута). */
submitLabel?: string;
/** Подпись той же кнопки во время запроса — по умолчанию «Сохранение...». */
savingLabel?: string;
onSubmit: () => void;
onClose: () => void;
children: React.ReactNode;
@@ -18,7 +22,16 @@ export interface IFormModalProps {
* Модальное окно формы: одинаковая обвязка «ошибка сверху, Отмена/Сохранить снизу»
* для всех справочников, чтобы экраны описывали только поля.
*/
export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, onSubmit, onClose, children }) => (
export const FormModal: React.FC<IFormModalProps> = ({
title,
error,
isSaving,
submitLabel = texts.common.save,
savingLabel = texts.common.saving,
onSubmit,
onClose,
children
}) => (
<Modal
title={title}
onClose={onClose}
@@ -26,7 +39,7 @@ export const FormModal: React.FC<IFormModalProps> = ({ title, error, isSaving, o
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>{texts.common.cancel}</Button>
<Button variant="accent" icon="check" onClick={onSubmit} disabled={isSaving}>
{isSaving ? texts.common.saving : texts.common.save}
{isSaving ? savingLabel : submitLabel}
</Button>
</>
}
@@ -24,7 +24,8 @@ export type IconName =
| 'briefcase'
| 'flag'
| 'crown'
| 'refresh';
| 'refresh'
| 'copy';
let spriteInjected = false;
@@ -51,6 +52,7 @@ const SPRITE_SVG = `
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 3v18"/><path d="M5 4h13l-3 4 3 4H5"/></symbol>
<symbol id="i-crown" viewBox="0 0 24 24"><path d="m3 8 4 3 5-6 5 6 4-3-2 11H5z"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></symbol>
`;
function ensureSpriteInjected(): void {
@@ -33,4 +33,18 @@ export interface Employee {
* подсказка при выборе сотрудника (см. ManageRequestsApproverModal), не для
* отображения в списке (сам справочник должностей не показывает). */
position: string;
/**
* Первая компания сотрудника из справочника ('' если не пришла) — для
* отображения в пикере/списках, где на человека нужна ровно одна строка.
*/
company: string;
/**
* ВСЕ компании, где числится сотрудник — совместители и штат общих служб
* (бухгалтерия, HR) на несколько юрлиц не редкость: в api.tnsu.kz это
* отдельная строка на каждую компанию, а не одна с списком. Проверять
* принадлежность к конкретной компании (маршрут ограничен ею) нужно по
* этому полю, а не по company — иначе сотрудник, у которого в справочнике
* первой оказалась ДРУГАЯ его компания, ошибочно выглядел бы отсутствующим.
*/
companies: string[];
}
@@ -22,6 +22,7 @@ export enum HeadType {
Chief = 'Chief',
DirectionHead = 'DirectionHead',
DirectorTansu = 'DirectorTansu',
ManagingDirectorTansu = 'ManagingDirectorTansu',
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
@@ -37,6 +37,19 @@ describe('parseRoute', () => {
expect(parseRoute('?aatab=routes').routeId).toBeUndefined();
});
it('aarfilter учитывается только при tab=routes', () => {
expect(parseRoute('?aatab=routes&aarfilter=11').routesFilter).toBe('11');
expect(parseRoute('?aatab=manageRequests&aarfilter=11').routesFilter).toBeUndefined();
});
// Фильтр живёт и при открытых шагах: возврат «к маршрутам» должен вернуть
// к тому же отфильтрованному списку (см. AdminApp.onBack).
it('aarfilter читается вместе с aaroute', () => {
const route = parseRoute('?aatab=routes&aaroute=7&aarfilter=11');
expect(route.routeId).toBe(7);
expect(route.routesFilter).toBe('11');
});
it('resolve.fieldValues парсит валидный JSON-массив', () => {
const fields = encodeURIComponent(JSON.stringify([{ fieldName: 'Amount', value: '100' }]));
const route = parseRoute(`?aatab=resolve&aartype=1&aafields=${fields}`);
+11 -3
View File
@@ -43,6 +43,12 @@ export interface IAdminRoute {
/** Id маршрута — только при tab='routes': сразу открывает шаги ЭТОГО маршрута,
* минуя список (см. AdminApp — по нему домаршрут дочитывается через api.templateRoutes.get). */
routeId?: number;
/**
* Фильтр списка маршрутов по типу заявки (id как строка, '' — все типы).
* Живёт и при открытых шагах (routeId): возврат «к маршрутам» должен вернуть
* к тому же отфильтрованному списку, а не сбросить фильтр.
*/
routesFilter?: string;
/** Задан только при tab='resolve' и непустом requestTypeId. */
resolve?: IResolveRouteParams;
/** Задан только при tab='manageRequests' и непустых requestTypeId+requestId. */
@@ -56,7 +62,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change';
const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'manageRequests'];
const PARAM_KEYS = [
'aatab', 'aaroute',
'aatab', 'aaroute', 'aarfilter',
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
'aamtype', 'aamreq'
];
@@ -114,6 +120,7 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
return {
tab,
routeId: routeId && !isNaN(routeId) ? routeId : undefined,
routesFilter: (tab === 'routes' ? params.get('aarfilter') : null) || undefined,
resolve,
manage
};
@@ -127,8 +134,9 @@ function buildQuery(route: IAdminRoute): string {
if (route.tab !== 'dashboard') params.set('aatab', route.tab);
if (route.tab === 'routes' && route.routeId) {
params.set('aaroute', String(route.routeId));
if (route.tab === 'routes') {
if (route.routeId) params.set('aaroute', String(route.routeId));
if (route.routesFilter) params.set('aarfilter', route.routesFilter);
}
if (route.tab === 'resolve' && route.resolve && route.resolve.requestTypeId) {
@@ -116,45 +116,3 @@ export class AadApiHttpClient implements IHttpClient {
.then((r: HttpClientResponse) => parseResponse<T>(r));
}
}
/**
* Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
* Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
* (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
*/
export class DevApiHttpClient implements IHttpClient {
constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
public get<T>(path: string, query?: QueryParams): Promise<T> {
return this.send<T>('GET', path, undefined, query);
}
public post<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('POST', path, body);
}
public put<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PUT', path, body);
}
public patch<T>(path: string, body?: unknown): Promise<T> {
return this.send<T>('PATCH', path, body);
}
public async delete(path: string): Promise<void> {
await this.send<void>('DELETE', path);
}
private async send<T>(method: string, path: string, body?: unknown, query?: QueryParams): Promise<T> {
const url = buildUrl(this.baseUrl, path, query);
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-Dev-User-Email': this.devUserEmail
},
body: body !== undefined ? JSON.stringify(body) : undefined
});
return parseResponse<T>(response);
}
}
@@ -53,6 +53,7 @@ interface IPortalEmployee {
fio: string;
email: string | null;
position: string | null;
company: string | null;
}
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
@@ -112,11 +113,21 @@ export class PortalDictionaryClient {
if (!x.email) return;
const existing = byEmail.get(x.email);
if (!existing) {
byEmail.set(x.email, { fio: x.fio, email: x.email, position: x.position || '' });
} else if (!existing.position && x.position) {
// Один сотрудник — строка на каждый его отдел; должность могла найтись
// не в первой из них.
existing.position = x.position;
byEmail.set(x.email, {
fio: x.fio,
email: x.email,
position: x.position || '',
company: x.company || '',
// Одна строка на каждую компанию, где числится сотрудник (совместители/
// бухгалтерия на несколько юрлиц — не редкость), companies собирает их
// все, а company остаётся первой встреченной (для пикера/отображения,
// где нужна ровно одна строка на человека).
companies: x.company ? [x.company] : []
});
} else {
if (!existing.position && x.position) existing.position = x.position;
if (!existing.company && x.company) existing.company = x.company;
if (x.company && existing.companies.indexOf(x.company) === -1) existing.companies.push(x.company);
}
});
return Array.from(byEmail.values());
+78 -3
View File
@@ -31,6 +31,7 @@ export const headTypeLabels: Record<HeadType, string> = {
[HeadType.Chief]: 'Непосредственный руководитель',
[HeadType.DirectionHead]: 'Руководитель направления',
[HeadType.DirectorTansu]: 'Директор Tansu',
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
};
@@ -158,6 +159,7 @@ export const texts = {
filterAll: 'Все типы заявок',
dictionaryFailed: (message: string): string => `Справочник компаний и отделов недоступен: ${message}`,
emptyList: 'Маршрутов пока нет.',
columnId: '№ маршрута',
columnRequestType: 'Тип заявки',
columnCompany: 'Компания',
columnDepartment: 'Отдел',
@@ -185,6 +187,38 @@ export const texts = {
controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.',
// ---- Копирование маршрута ----
copy: 'Копировать',
copyTitle: (routeId: number): string => `Копия маршрута #${routeId}`,
copyAction: 'Создать копию',
copyInProgress: 'Копирование...',
copyHint:
'Копия создаётся для того же типа заявки вместе со всеми шагами. Маршрут различается по компании, ' +
'отделу и признаку «по проекту» — измените хотя бы одно из них, иначе копия совпадёт с исходным маршрутом.',
copySource: (requestTypeName: string, company: string, department: string): string =>
`Копируем: ${requestTypeName} · ${company} · ${department}`,
copyStepsCount: (count: number): string =>
`Шагов будет скопировано: ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])}`,
copyStepsLoading: 'Загрузка шагов исходного маршрута...',
copyStepsEmpty: 'В исходном маршруте нет шагов — копия будет пустой.',
validationCopySame:
'Копия совпадает с исходным маршрутом. Измените компанию, отдел или признак «по проекту» — ' +
'иначе такой маршрут уже существует.',
// Роль в проекте допустима только у маршрута с признаком «по проекту» (проверка
// на бэкенде, TemplateRouteStepService) — предупреждаем ДО копирования, иначе
// копия сорвётся на середине переноса шагов.
copyProjectRoleWarning: (count: number): string =>
`В исходном маршруте ${count} ${pluralRu(count, ['шаг', 'шага', 'шагов'])} с ролью в проекте. ` +
'Без признака «по проекту» такие шаги скопировать нельзя — включите его или удалите эти шаги в исходном маршруте.',
copyStepFailed: (orderNo: number, message: string): string =>
`Не удалось скопировать шаг ${orderNo}: ${message}`,
// Частично скопированный маршрут опаснее, чем несозданный: он бы резолвился
// с недостающими согласующими. Поэтому при ошибке копию удаляем целиком.
copyRolledBack: 'Копия не создана — маршрут удалён, чтобы не остался наполовину скопированным.',
copyRollbackFailed: (routeId: number): string =>
`ВНИМАНИЕ: маршрут #${routeId} создан, но шаги скопированы не полностью, и удалить его автоматически не вышло. Удалите маршрут вручную.`,
deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`
@@ -193,10 +227,20 @@ export const texts = {
routeSteps: {
breadcrumb: (routeId: number): string => `Шаги маршрута #${routeId}`,
title: (routeId: number): string => `Маршрут #${routeId}`,
subtitle: (requestTypeName: string): string => requestTypeName,
/** Подзаголовок экрана шагов: по нему видно, ЧЕЙ это маршрут (тип заявки · компания · отдел). */
subtitle: (requestTypeName: string, company: string, department: string): string =>
`${requestTypeName} · ${company} · ${department}`,
/** Маршрут без компании/отдела — запасной, применяется когда своего маршрута нет. */
anyCompany: 'любая компания',
anyDepartment: 'любой отдел',
back: 'К маршрутам',
add: 'Добавить шаг',
emptyList: 'Шагов пока нет — добавьте первый.',
moveUp: 'Шаг выше',
moveDown: 'Шаг ниже',
mergeWithPrev: 'Объединить с предыдущим шагом (выполнять параллельно)',
splitFromPrev: 'Отделить от предыдущего шага (выполнять после него)',
reorderFailed: (message: string): string => `Не удалось изменить порядок шагов: ${message}`,
columnOrderNo: '№',
columnExecutor: 'Исполнитель',
columnStatus: 'Статус',
@@ -220,6 +264,32 @@ export const texts = {
executorPlaceholder: 'Выберите сотрудника',
positionLabel: 'Должность',
positionHint: 'Ищется в справочнике api.tnsu.kz по компании инициатора.',
// Подсказка «кого найдёт эта должность» — считается по загруженному справочнику.
positionPreviewLoading: 'Проверяем по справочнику сотрудников...',
positionPreviewFound: (count: number): string =>
count === 1
? 'Сейчас эту должность занимает:'
: `Эту должность занимают ${count} ${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])} — ` +
'на согласование пойдёт тот, чья компания совпадёт с компанией инициатора:',
// У маршрута задана конкретная компания (не запасной «любая компания») —
// резолвиться будет только сотрудник этой компании, остальные найденные
// (в других компаниях) этому маршруту не подойдут ни при каких условиях.
positionPreviewFoundInCompany: (company: string, count: number): string =>
count === 1
? `Эту должность в компании «${company}» (компания маршрута) занимает:`
: `Эту должность в компании «${company}» (компания маршрута) занимают ${count} ` +
`${pluralRu(count, ['сотрудник', 'сотрудника', 'сотрудников'])}:`,
positionPreviewWrongCompany: (company: string): string =>
`Такая должность есть в справочнике, но не в компании «${company}» — а маршрут применяется именно к её ` +
'инициаторам. При резолве шаг останется без исполнителя.',
positionPreviewOtherCompanies: 'Есть в других компаниях:',
positionPreviewNone:
'Точно такой должности в справочнике нет — на резолве шаг останется без исполнителя. Проверьте написание.',
positionPreviewSimilar: 'Похожие должности в справочнике (нажмите, чтобы подставить точное написание):',
/** Сколько ещё человек занимают ту же должность, кроме показанного. */
positionPreviewMore: (count: number): string =>
`и ещё ${count} ${pluralRu(count, ['человек', 'человека', 'человек'])}`,
positionPreviewNoDictionary: 'Справочник сотрудников недоступен — проверить должность не удалось.',
positionPlaceholder: 'Главный инженер',
projectRoleLabel: 'Роль в проекте',
headTypeLabel: 'Тип руководителя',
@@ -233,7 +303,9 @@ export const texts = {
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
conditionDisabledHint:
'У типа заявки не указан список SharePoint (list_name) — доступно только служебное поле «Инициатор — руководитель».',
conditionFieldIsManagerLabel: 'Должность в заявке — руководящая (IsManager)',
conditionFieldNameLabel: 'Поле списка',
conditionFieldNamePlaceholder: 'Выберите столбец списка',
conditionFieldNameLoading: 'Загрузка столбцов списка...',
@@ -371,7 +443,10 @@ export const texts = {
missingInDictionary: 'Нет в справочнике',
loadingDictionary: 'Загрузка справочника...',
searching: 'Поиск...',
employeeSearchPlaceholder: 'Поиск по ФИО или email...',
// Не перечисляем поля: искать можно и по ФИО, и по email, и по должности
// (см. toEmployeeOption — должность попадает в sublabel, а SearchableSelect
// ищет и по нему). Перечисление всё равно устарело бы при следующем поле.
employeeSearchPlaceholder: 'Поиск...',
employeesEmpty: 'Сотрудники не найдены в справочнике'
}
};
@@ -1,9 +1,16 @@
import { Employee } from '../models';
import { findEmployeeByEmail, toEmployeeOption } from './employee';
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
// Хелпер вместо повторения company/companies в каждом литерале — в тестах
// нет нужды в разных компаниях у одного сотрудника (это отдельно проверяется
// в matchEmployeesByPosition ниже).
function emp(fio: string, email: string, position: string, company = ''): Employee {
return { fio, email, position, company, companies: company ? [company] : [] };
}
const employees: Employee[] = [
{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle' },
{ fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела' }
emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
];
describe('findEmployeeByEmail', () => {
@@ -26,11 +33,74 @@ describe('findEmployeeByEmail', () => {
});
describe('toEmployeeOption', () => {
it('строит опцию для SearchableSelect из сотрудника', () => {
it('строит опцию для SearchableSelect из сотрудника: должность и email в подписи', () => {
expect(toEmployeeOption(employees[0])).toEqual({
value: 'gorbenko.pavel@tnsu.kz',
label: 'Горбенко Павел',
sublabel: 'gorbenko.pavel@tnsu.kz'
sublabel: 'Разработчик middle · gorbenko.pavel@tnsu.kz'
});
});
// Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя.
it('без должности в подписи остаётся только email', () => {
expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
value: 'nobody@tnsu.kz',
label: 'Без Должности',
sublabel: 'nobody@tnsu.kz'
});
});
});
describe('matchEmployeesByPosition', () => {
const staff: Employee[] = [
emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
];
it('пустой ввод — ничего не подбирает', () => {
expect(matchEmployeesByPosition(staff, ' ')).toEqual({ exact: [], similar: [] });
});
// Бэкенд ищет точное совпадение должности, поэтому подсказка обязана считать
// так же — иначе обещала бы исполнителя там, где его не найдут.
it('точное совпадение не зависит от регистра и краевых пробелов', () => {
const result = matchEmployeesByPosition(staff, ' генеральный ДИРЕКТОР ');
expect(result.exact.map(e => e.fio)).toEqual(['Плотникова Лариса']);
});
it('одну должность в разных компаниях занимают несколько человек — возвращаются все', () => {
const result = matchEmployeesByPosition(staff, 'Инженер ПТО');
expect(result.exact.map(e => e.company)).toEqual(['Компания А', 'Компания Б']);
});
// 'директор' — сам по себе точное совпадение (должность Самаевой), а
// подстрокой входит ещё в две других — те и предлагаются похожими.
it('точное совпадение и похожие должности возвращаются одновременно', () => {
const result = matchEmployeesByPosition(staff, 'директор');
expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']);
expect(result.similar.map(s => s.position)).toEqual(['Генеральный директор', 'Управляющий директор']);
});
// Список нужен, чтобы подставить точное написание — одинаковые строки в нём бесполезны.
it('похожие схлопываются по написанию и считают людей', () => {
expect(matchEmployeesByPosition(staff, 'инженер').similar).toEqual([
{ position: 'Инженер ПТО', fio: 'Иванов Иван', count: 2 }
]);
});
it('точное совпадение не дублируется в похожих', () => {
const result = matchEmployeesByPosition(staff, 'Директор');
expect(result.exact.map(e => e.fio)).toEqual(['Самаева Асель']);
expect(result.similar.some(s => s.position === 'Директор')).toBe(false);
});
it('сотрудники без должности не попадают в подбор', () => {
expect(matchEmployeesByPosition(staff, '').exact).toEqual([]);
expect(matchEmployeesByPosition(staff, 'директор').similar.some(s => s.position === '')).toBe(false);
});
});
@@ -1,9 +1,17 @@
import { Employee } from '../models';
import { ISearchableOption } from '../components/ui';
/** Опция для SearchableSelect: ФИО — основная строка, email — маленькая подпись под ней. */
/**
* Опция для SearchableSelect: ФИО — основная строка, должность и email — подпись под ней.
*
* Должность здесь не только для узнавания однофамильцев: SearchableSelect ищет и по
* sublabel (см. filtered там), поэтому согласующего можно найти, набрав «начальник омтс»,
* не помня фамилии. Должность может быть пустой (в справочнике не у всех заполнена) —
* тогда в подписи остаётся только email.
*/
export function toEmployeeOption(employee: Employee): ISearchableOption {
return { value: employee.email, label: employee.fio, sublabel: employee.email };
const sublabel = [employee.position, employee.email].filter(Boolean).join(' · ');
return { value: employee.email, label: employee.fio, sublabel };
}
export function findEmployeeByEmail(employees: Employee[], email: string | undefined): Employee | undefined {
@@ -11,3 +19,67 @@ export function findEmployeeByEmail(employees: Employee[], email: string | undef
const target = email.toLowerCase();
return employees.find(e => e.email.toLowerCase() === target);
}
export interface SimilarPosition {
/** Написание должности как в справочнике — его и подставляем по клику. */
position: string;
/**
* ФИО первого найденного сотрудника с этой должностью — по одному названию
* не всегда понятно, тот ли это человек, что нужен; имя снимает вопрос сразу,
* без подстановки и повторной проверки.
*/
fio: string;
/** Сколько сотрудников её занимают (в разных компаниях/отделах). */
count: number;
}
export interface PositionMatches {
/**
* Кого бэкенд реально подставит: он ищет ТОЧНОЕ совпадение должности
* (/Employee/ByPosition), поэтому здесь тоже точное — с точностью до регистра
* и краевых пробелов. Несколько человек — норма: должность одна, а компании
* разные, и на resolve выберется тот, чья компания совпала с компанией инициатора.
*/
exact: Employee[];
/**
* Похожие должности — на случай опечатки или неполного ввода. Уникальные
* по написанию, а не по сотруднику: список нужен, чтобы подставить в поле
* точный текст должности, и пять одинаковых строк там бесполезны.
* Точное совпадение сюда не попадает — оно уже показано в exact.
*/
similar: SimilarPosition[];
}
/** Максимум похожих в подсказке — длинный список её только зашумит. */
const SIMILAR_LIMIT = 6;
/**
* Кого найдёт шаг «по должности в компании инициатора» с таким текстом должности.
* Считается по уже загруженному справочнику (без запроса на каждый символ).
*/
export function matchEmployeesByPosition(employees: Employee[], position: string): PositionMatches {
const query = position.trim().toLowerCase();
if (!query) return { exact: [], similar: [] };
const exact: Employee[] = [];
// Ключ — нормализованное написание, значение — как показать и сколько человек.
const similarByKey = new Map<string, SimilarPosition>();
employees.forEach(e => {
const raw = e.position.trim();
const candidate = raw.toLowerCase();
if (!candidate) return;
if (candidate === query) {
exact.push(e);
return;
}
if (candidate.indexOf(query) === -1) return;
const existing = similarByKey.get(candidate);
if (existing) existing.count += 1;
else similarByKey.set(candidate, { position: raw, fio: e.fio, count: 1 });
});
return { exact, similar: Array.from(similarByKey.values()).slice(0, SIMILAR_LIMIT) };
}
@@ -44,11 +44,28 @@ describe('detectExecutorKind', () => {
});
describe('describeStepExecutor', () => {
const employees: Employee[] = [{ fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик' }];
const employees: Employee[] = [
{
fio: 'Горбенко Павел',
email: 'gorbenko.pavel@tnsu.kz',
position: 'Разработчик',
company: 'ТОО TANSU Construction',
companies: ['ТОО TANSU Construction']
}
];
it('для email найденного сотрудника показывает ФИО и email отдельной строкой', () => {
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
const result = describeStepExecutor(step({ executorEmail: 'gorbenko.pavel@tnsu.kz' }), employees);
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'gorbenko.pavel@tnsu.kz' });
expect(result).toEqual({ primary: 'Горбенко Павел', secondary: 'Разработчик · gorbenko.pavel@tnsu.kz' });
});
// Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя.
it('для сотрудника без должности в подписи остаётся только email', () => {
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
{ fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '', companies: [] }
]);
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
});
it('для email, которого нет в справочнике, показывает сам email без secondary', () => {
@@ -19,7 +19,7 @@ export function detectExecutorKind(step: {
export interface StepExecutorDisplay {
primary: string;
/** Email под ФИО — только когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
/** Должность и email под ФИО — когда исполнитель задан конкретным сотрудником и тот найден в справочнике. */
secondary?: string;
}
@@ -27,7 +27,10 @@ export interface StepExecutorDisplay {
export function describeStepExecutor(step: TemplateRouteStep, employees: Employee[]): StepExecutorDisplay {
if (step.executorEmail) {
const employee = findEmployeeByEmail(employees, step.executorEmail ?? undefined);
return employee ? { primary: employee.fio, secondary: employee.email } : { primary: step.executorEmail };
if (!employee) return { primary: step.executorEmail };
// Должность рядом с email: по одному ФИО не видно, тот ли это человек, что
// задумывался при настройке маршрута (в справочнике есть однофамильцы).
return { primary: employee.fio, secondary: [employee.position, employee.email].filter(Boolean).join(' · ') };
}
if (step.position) return { primary: step.position };
if (step.projectRole && step.projectRole !== ProjectRole.None) {