using System.Globalization; using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed class TemplateRouteService( ApprovalRouteDbContext db, ITnsuPortalClient portalClient, ICurrentUser currentUser, IOptions portalOptions, ILogger logger) { public async Task>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) { var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); if (requestTypeId is { } id) query = query.Where(x => x.RequestTypeId == id); var items = await query .Select(x => new TemplateRouteDto( x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) .ToListAsync(cancellationToken); return Result.Ok>(items); } public async Task> GetAsync(int id, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); return entity is null ? Result.Fail(new NotFoundError("Маршрут", id)) : Result.Ok(ToDto(entity)); } public async Task> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken) { var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); if (!requestTypeExists) return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); var entity = new TemplateRoute { RequestTypeId = r.RequestTypeId, DepartmentId = r.DepartmentId, CompanyId = r.CompanyId, ByProject = r.ByProject, RequiresControl = r.RequiresControl }; db.TemplateRoutes.Add(entity); await db.SaveChangesAsync(cancellationToken); logger.LogInformation("Маршрут {RouteId} создан пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } public async Task> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); entity.DepartmentId = r.DepartmentId; entity.CompanyId = r.CompanyId; entity.ByProject = r.ByProject; entity.RequiresControl = r.RequiresControl; await db.SaveChangesAsync(cancellationToken); logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } public async Task> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); var departmentId = r.DepartmentId ?? entity.DepartmentId; var companyId = r.CompanyId ?? entity.CompanyId; var byProject = r.ByProject ?? entity.ByProject; var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); entity.DepartmentId = departmentId; entity.CompanyId = companyId; entity.ByProject = byProject; entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl; await db.SaveChangesAsync(cancellationToken); logger.LogInformation("Маршрут {RouteId} изменён пользователем {Author}", entity.Id, currentUser.Email); return Result.Ok(ToDto(entity)); } public async Task DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); db.TemplateRoutes.Remove(entity); await db.SaveChangesAsync(cancellationToken); logger.LogInformation("Маршрут {RouteId} удалён пользователем {Author}", id, currentUser.Email); return Result.Ok(); } public async Task> ResolveAsync( ResolveTemplateRouteRequest request, CancellationToken cancellationToken) { var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail; if (string.IsNullOrWhiteSpace(email)) return Result.Fail( new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); // Сам тип заявки, а не только факт его существования: из него берутся // значения SLA по умолчанию для шагов (см. ResolveStepAsync). var requestType = await db.RequestTypes.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.RequestTypeId, cancellationToken); if (requestType is null) return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); if (employee is null) return Result.Fail( new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email)); var byProject = request.ProjectId is not null; // Приоритет — маршрут с компанией инициатора; если для этой компании // своего маршрута нет, берём запасной маршрут без указанной компании // (тот же принцип, что уже применяется к отделу внутри FindRouteAsync). var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken) ?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken); if (route is null) return Result.Fail(new NotFoundError( "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", request.RequestTypeId)); IReadOnlyList projectExecutors = []; if (request.ProjectId is { } projectId) projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); var fieldValues = (request.FieldValues ?? []) .Where(f => !string.IsNullOrWhiteSpace(f.FieldName)) .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) || HasUnevaluatableManagerCondition(s, positionMissing)) .OrderBy(s => s.OrderNo) .ToList(); // Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные // походы в 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); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } /// /// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное /// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела /// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает /// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null). /// private async Task FindRouteAsync( int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) => await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && x.ByProject == byProject && x.DepartmentId == departmentId) .FirstOrDefaultAsync(cancellationToken) ?? await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && x.ByProject == byProject && x.DepartmentId == null) .FirstOrDefaultAsync(cancellationToken); private Task HasDuplicateAsync( int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) => db.TemplateRoutes.AnyAsync(x => x.Id != excludeId && x.RequestTypeId == requestTypeId && x.DepartmentId == departmentId && x.CompanyId == companyId && x.ByProject == byProject, cancellationToken); /// /// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про /// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// private async Task ResolveStepAsync( TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList projectExecutors, Guid? departmentId, RequestType requestType, string? conditionWarning, CancellationToken cancellationToken) { string? resolvedEmail; string? warning = null; if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) { resolvedEmail = step.ExecutorEmail; } else if (!string.IsNullOrWhiteSpace(step.Position)) { var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); resolvedEmail = employeeByPosition.Email; if (!employeeByPosition.Found) warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}»."; else if (string.IsNullOrWhiteSpace(resolvedEmail)) warning = $"Сотрудник {employeeByPosition.Fio} на должности «{step.Position}» найден, но email не указан в системе портала."; } else if (step.ProjectRole is not (null or ProjectRole.None)) { resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; if (string.IsNullOrWhiteSpace(resolvedEmail)) warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}»."; } // DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке // подчинённости инициатора, а по отделу, переданному явно в запросе (см. // ResolveTemplateRouteRequest.DepartmentId): отдел инициатора тут ни при // чём, шаг про «руководителя ТАКОГО-ТО отдела», а не «моего руководителя». // Поэтому у него отдельная ветка, а не место в switch чуть ниже. else if (step.HeadType == HeadType.DepartmentHead) { if (departmentId is null) { resolvedEmail = null; warning = "Шаг требует руководителя указанного отдела, но departmentId не передан в запросе."; } else { resolvedEmail = await portalClient.GetDepartmentHeadEmailAsync(departmentId.Value, cancellationToken); if (string.IsNullOrWhiteSpace(resolvedEmail)) warning = $"Не найден руководитель отдела с id «{departmentId}»."; } } else if (step.HeadType is not (null or HeadType.None)) { resolvedEmail = step.HeadType switch { 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)) warning = $"Не найден руководитель типа «{step.HeadType}»."; } else { resolvedEmail = null; warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей."; } string? resolvedPosition = null; if (!string.IsNullOrWhiteSpace(resolvedEmail)) { var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken); resolvedPosition = resolvedEmployee?.Position; } // Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??` // работает именно так, как задумано — значение шага перекрывает типовое, // и при этом заданный на шаге 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, combinedWarning.Length == 0 ? null : combinedWarning, step.SlaHours ?? requestType.SlaHours, step.AskOverdueReason ?? requestType.AskOverdueReason); } /// /// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе. /// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля /// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный /// сценарий, и отсутствие значения означает, что условие не выполняется. /// private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary fieldValues) { if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None) return true; if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) return false; var expected = step.Value; return step.Operation switch { ComparisonOperator.Equal => string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) || (TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected), ComparisonOperator.GreaterThan => TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected, ComparisonOperator.LessThan => TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected, _ => true }; } private static bool TryParseNumber(string? value, out double number) => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); /// Поле заявки, из которого вычисляется . private const string PositionField = "Position"; /// Синтетическое поле условия шага — вычисляется, а не приходит из заявки. private const string ManagerConditionField = "IsManager"; private const string PositionMissingWarning = "В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " + "поэтому шаг включён в маршрут, хотя условие могло и не выполниться."; /// /// Условие шага завязано на IsManager, но вычислить его не из чего: должность /// в заявке не передана. Такой шаг попадает в результат вопреки условию и с /// предупреждением — см. ResolveAsync. /// private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) => positionMissing && string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) && step.Operation is not (null or ComparisonOperator.None); /// /// Должность — свободный текст без структуры (нет отдельного флага «это /// руководитель»), поэтому определяем это по ключевым словам в её названии. /// Список подобран по фактическим должностям в справочнике api.tnsu.kz — /// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом. /// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший /// специалист, а не должность с подчинёнными. /// private static readonly string[] ManagerPositionKeywords = ["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"]; private static bool IsManagerPosition(string? position) => !string.IsNullOrWhiteSpace(position) && ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase)); private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) { var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); var result = new List(); foreach (var step in steps) { if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail)) continue; result.Add(step); } return result; } private static TemplateRouteDto ToDto(TemplateRoute e) => new( e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); }