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)); // /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких // компаниях одновременно. Для поиска маршрута и большинства резолвов ниже // используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно // одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors // (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все. var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); if (employees.Count == 0) return Result.Fail( new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email)); var primaryEmployee = employees[0]; var initiatorCompanies = employees .Select(e => e.Company) .Where(c => !string.IsNullOrWhiteSpace(c)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var byProject = request.ProjectId is not null; // Приоритет — маршрут с компанией инициатора; если для этой компании // своего маршрута нет, берём запасной маршрут без указанной компании // (тот же принцип, что уже применяется к отделу внутри FindRouteAsync). var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken) ?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.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 => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing)) .OrderBy(s => s.OrderNo) .ToList(); // Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные // походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте // (десяток+ шагов) последовательные await один за другим складываются в // заметную задержку. Task.WhenAll сохраняет порядок результатов (массив // соответствует порядку задач, а не порядку завершения), поэтому // OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ // дубликат считается первым) продолжают работать так же, как при // последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО // строк результата (HeadType.CompanyDirectors — по одной на компанию // инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto, // и результат нужно сначала сгладить (SelectMany). var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync( step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType, HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, cancellationToken))); var flattenedSteps = stepResults.SelectMany(x => x).ToList(); var (toCollapse, directorTansuAfterCompanyDirectors) = SplitDirectorTansuAfterCompanyDirectors(flattenedSteps); var collapsedSteps = CollapseByExecutorEmail(toCollapse) .Concat(directorTansuAfterCompanyDirectors) .ToList(); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } /// /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU /// Construction. Оба шага в маршруте законны сами по себе, и оба должны попасть в /// результат — это НЕ дубликат одного согласования, а два разных: сначала блок /// директоров компаний параллельно, а затем — отдельно — Директор Tansu, даже если /// физически это тот же человек. Поэтому строку DirectorTansu с таким совпадением /// НЕ пускаем через обычное схлопывание по email (см. CollapseByExecutorEmail) — /// оно бы решило, что это конфликт PositionCode одного согласующего, и либо /// смержило бы её с строкой CompanyDirectors, либо просто выбрало одну из них. /// Вместо этого явно переставляем её OrderNo на «после всего блока /// CompanyDirectors» — не параллельно ему, а следом. /// /// /// (toCollapse, directorTansuAfterCompanyDirectors): первый список идёт через /// обычное схлопывание по email, второй — строки DirectorTansu, которые нужно /// просто дописать в конец результата без схлопывания. /// private static (IReadOnlyList ToCollapse, IReadOnlyList DirectorTansuAfterCompanyDirectors) SplitDirectorTansuAfterCompanyDirectors(IReadOnlyList steps) { var companyDirectorSteps = steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList(); var companyDirectorEmails = companyDirectorSteps .Where(s => !string.IsNullOrWhiteSpace(s.ExecutorEmail)) .Select(s => s.ExecutorEmail!) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (companyDirectorEmails.Count == 0) return (steps, []); var maxCompanyDirectorsOrderNo = companyDirectorSteps.Max(s => s.OrderNo); var toCollapse = new List(); var afterBlock = new List(); foreach (var step in steps) { if (step.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(step.ExecutorEmail) && companyDirectorEmails.Contains(step.ExecutorEmail)) { afterBlock.Add(step with { OrderNo = maxCompanyDirectorsOrderNo + 1 }); } else { toCollapse.Add(step); } } return (toCollapse, afterBlock); } /// /// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное /// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела /// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает /// вызывающий код (см. 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).ThenInclude(s => s.Conditions) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && x.ByProject == byProject && x.DepartmentId == departmentId) .FirstOrDefaultAsync(cancellationToken) ?? await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps).ThenInclude(s => s.Conditions) .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, а не здесь. Если исполнитель к тому же не резолвится — тексты /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// /// /// Название должности директора для конкретной компании — по умолчанию «Директор», /// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»), /// поэтому для HeadType.CompanyDirectors ищем по этому названию через /// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/ /// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же: /// отдельного источника правды для него в системе нет. /// private static readonly IReadOnlyDictionary DirectorPositionByCompany = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["ТОО KazPromService"] = "Главный механик", ["ТОО СТ Global"] = "Генеральный директор" }; private const string DefaultDirectorPosition = "Директор"; private static string DirectorPositionFor(string company) => DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition; /// /// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для /// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию /// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном /// шаге, то есть параллельны по построению, а не потому что кто-то расставил /// им одинаковый порядок вручную. /// /// /// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны /// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему /// используют одну (первую по счёту). /// /// /// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про /// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// private async Task> ResolveStepAsync( TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList initiatorCompanies, IReadOnlyList projectExecutors, Guid? departmentId, RequestType requestType, string? conditionWarning, CancellationToken cancellationToken) { // Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??` // работает именно так, как задумано — значение шага перекрывает типовое, // и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым // (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours). async Task BuildDtoAsync(string? resolvedEmail, string? warning) { string? resolvedPosition = null; if (!string.IsNullOrWhiteSpace(resolvedEmail)) { var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken); resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position; } 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, step.HeadType is null or HeadType.None ? null : step.HeadType); } // CompanyDirectors — единственная ветка, которая резолвится в несколько строк // (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно // и до общего "одна строка на шаг" пути ниже. if (step.HeadType == HeadType.CompanyDirectors) { if (initiatorCompanies.Count == 0) return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")]; var dtos = new List(); foreach (var company in initiatorCompanies) { var directorPosition = DirectorPositionFor(company); var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken); var directorEmail = employeeByPosition.Email; string? directorWarning = null; if (!employeeByPosition.Found) directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}»."; else if (string.IsNullOrWhiteSpace(directorEmail)) directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала."; dtos.Add(await BuildDtoAsync(directorEmail, directorWarning)); } return dtos; } 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 = "Не удалось определить исполнителя шага: не заполнено ни одно из полей."; } return [await BuildDtoAsync(resolvedEmail, warning)]; } /// /// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий /// применяется всегда. Несколько условий объединяются через : /// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано /// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы /// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется. /// private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary fieldValues) { if (step.Conditions.Count == 0) return true; return step.ConditionsLogic == ConditionsLogic.Or ? step.Conditions.Any(c => MatchesCondition(c, fieldValues)) : step.Conditions.All(c => MatchesCondition(c, fieldValues)); } private static bool MatchesCondition(TemplateRouteStepCondition condition, IReadOnlyDictionary fieldValues) { if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) return false; var expected = condition.Value; return condition.Operation switch { ComparisonOperator.Equal => IsEqual(actual, expected), ComparisonOperator.NotEqual => !IsEqual(actual, expected), ComparisonOperator.Contains => Contains(actual, expected), ComparisonOperator.NotContains => !Contains(actual, expected), 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 }; } /// /// «Равно» для значений без типа: сначала как текст без учёта регистра, затем как числа — /// чтобы «1000» и «1000.00» считались равными. «Не равно» — строгое отрицание этого же. /// private static bool IsEqual(string actual, string? expected) => string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) || (TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected); /// «Содержит» — подстрока без учёта регистра; операторы доступны только для текстовых полей. private static bool Contains(string actual, string? expected) => !string.IsNullOrEmpty(expected) && actual.Contains(expected.Trim(), StringComparison.OrdinalIgnoreCase); 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. Не различаем И/ИЛИ и остальные условия шага: /// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен. /// private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) => positionMissing && step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase)); /// /// Должность — свободный текст без структуры (нет отдельного флага «это /// руководитель»), поэтому определяем это по ключевым словам в её названии. /// Список подобран по фактическим должностям в справочнике 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)); /// /// Один и тот же исполнитель, полученный из разных шагов, в результате должен /// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode /// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки /// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он /// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две /// несовместимые роли шага, например Chief и CompanyDirectors одновременно) — /// это не должно молча выбрать одну из них без следа: добавляем предупреждение /// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности /// хуже, чем показать чуть неточный PositionCode с пояснением. /// private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) { var indexByEmail = new Dictionary(StringComparer.OrdinalIgnoreCase); var result = new List(); foreach (var step in steps) { if (string.IsNullOrWhiteSpace(step.ExecutorEmail)) { result.Add(step); continue; } if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index)) { indexByEmail[step.ExecutorEmail] = result.Count; result.Add(step); continue; } var existing = result[index]; if (existing.PositionCode is null && step.PositionCode is not null) { result[index] = existing with { PositionCode = step.PositionCode }; } else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode) { var conflictWarning = $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}."; var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning } .Where(w => !string.IsNullOrWhiteSpace(w))); result[index] = existing with { Warning = combinedWarning }; } } return result; } private static TemplateRouteDto ToDto(TemplateRoute e) => new( e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); }