From ba48ed1a0ed261d02ff3f02e57ad1dce11a7d85d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 25 Aug 2026 07:04:44 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=BE=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B0,=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8F=D0=B5=D0=BC=D1=8B=D1=85=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=98/=D0=98=D0=9B=D0=98=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors -- [GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors](http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/commit/572749b4d7cc11bc8e1ef4184a93f18a14b710ab) --------- Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/16 --- .../TemplateRouteStepDto.cs | 28 +- .../TemplateRouteStepService.cs | 211 ++++++---- .../TemplateRouteStepValidators.cs | 28 +- .../TemplateRoutes/ResolveTemplateRouteDto.cs | 10 +- .../TemplateRoutes/TemplateRouteService.cs | 263 ++++++++++--- .../Entities/TemplateRouteStep.cs | 8 +- .../Entities/TemplateRouteStepCondition.cs | 24 ++ .../Enums/ConditionsLogic.cs | 14 + .../Enums/HeadType.cs | 12 +- .../Persistence/ApprovalRouteDbContext.cs | 15 +- ...4822_AddMultipleStepConditions.Designer.cs | 366 ++++++++++++++++++ ...0260825044822_AddMultipleStepConditions.cs | 120 ++++++ .../ApprovalRouteDbContextModelSnapshot.cs | 82 +++- .../TnsuPortal/CachingTnsuPortalClient.cs | 2 +- .../TnsuPortal/HttpTnsuPortalClient.cs | 12 +- .../TnsuPortal/ITnsuPortalClient.cs | 7 +- .../TemplateRouteServiceTests.cs | 266 +++++++++++-- .../TemplateRouteStepServiceTests.cs | 153 +++++--- .../components/screens/RouteCopyModal.tsx | 5 +- .../screens/RouteStepFormModal.module.scss | 22 ++ .../components/screens/RouteStepFormModal.tsx | 255 ++++++------ .../webparts/approvalAdmin/models/enums.ts | 12 +- .../webparts/approvalAdmin/models/resolve.ts | 8 +- .../models/templateRouteSteps.ts | 30 +- frontend/src/webparts/approvalAdmin/texts.ts | 24 +- .../approvalAdmin/utils/stepExecutor.test.ts | 45 ++- .../approvalAdmin/utils/stepExecutor.ts | 13 +- 27 files changed, 1622 insertions(+), 413 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs create mode 100644 backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs create mode 100644 backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 5736e17..9b008b3 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -7,6 +7,9 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; // (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец // записей, чтобы не ломать существующие позиционные вызовы. +/// Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition. +public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value); + public sealed record TemplateRouteStepDto( int Id, int OrderNo, @@ -17,9 +20,8 @@ public sealed record TemplateRouteStepDto( RouteStepStatus Status, int RouteId, bool AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList Conditions, + ConditionsLogic ConditionsLogic, string? Warning = null, int? SlaHours = null, bool? AskOverdueReason = null); @@ -32,9 +34,8 @@ public sealed record CreateTemplateRouteStepRequest( HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList? Conditions, + ConditionsLogic ConditionsLogic = ConditionsLogic.And, int? SlaHours = null, bool? AskOverdueReason = null); @@ -46,12 +47,16 @@ public sealed record UpdateTemplateRouteStepRequest( HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList? Conditions, + ConditionsLogic ConditionsLogic = ConditionsLogic.And, int? SlaHours = null, bool? AskOverdueReason = null); +/// +/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить +/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше +/// для FieldName) — очистка условий делается через PUT с Conditions: []. +/// public sealed record PatchTemplateRouteStepRequest( int? OrderNo, string? ExecutorEmail, @@ -60,8 +65,7 @@ public sealed record PatchTemplateRouteStepRequest( HeadType? HeadType, RouteStepStatus? Status, bool? AllowReassignToOtherApprovers, - string? FieldName, - ComparisonOperator? Operation, - string? Value, + IReadOnlyList? Conditions, + ConditionsLogic? ConditionsLogic = null, int? SlaHours = null, bool? AskOverdueReason = null); diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 65d87d4..2fdb185 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -15,14 +15,12 @@ public sealed class TemplateRouteStepService( public async Task>> ListAsync(int routeId, CancellationToken cancellationToken) { var items = await db.TemplateRouteSteps.AsNoTracking() + .Include(x => x.Conditions) .Where(x => x.RouteId == routeId) .OrderBy(x => x.OrderNo) - .Select(x => new TemplateRouteStepDto( - x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, - x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) .ToListAsync(cancellationToken); - return Result.Ok>(items); + return Result.Ok>(items.Select(ToDto).ToList()); } /// @@ -38,19 +36,18 @@ public sealed class TemplateRouteStepService( return Result.Ok>(Array.Empty()); var items = await db.TemplateRouteSteps.AsNoTracking() + .Include(x => x.Conditions) .Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail)) .OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo) - .Select(x => new TemplateRouteStepDto( - x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, - x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason)) .ToListAsync(cancellationToken); - return Result.Ok>(items); + return Result.Ok>(items.Select(ToDto).ToList()); } public async Task> GetAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.AsNoTracking() + .Include(x => x.Conditions) .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); return entity is null @@ -68,22 +65,22 @@ public sealed class TemplateRouteStepService( return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); - if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + "В каждом условии (field_name, operation, value) все три поля обязательны.")); - if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) + if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); + "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id).")); var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, - r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken); + r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); - if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken)) + if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); @@ -97,9 +94,8 @@ public sealed class TemplateRouteStepService( HeadType = r.HeadType, Status = r.Status, AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, - FieldName = r.FieldName, - Operation = r.Operation, - Value = r.Value, + ConditionsLogic = r.ConditionsLogic, + Conditions = ToEntities(r.Conditions), SlaHours = r.SlaHours, AskOverdueReason = r.AskOverdueReason }; @@ -118,7 +114,8 @@ public sealed class TemplateRouteStepService( if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + var entity = await db.TemplateRouteSteps.Include(x => x.Conditions) + .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Шаг маршрута", id)); @@ -126,22 +123,22 @@ public sealed class TemplateRouteStepService( return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); - if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + "В каждом условии (field_name, operation, value) все три поля обязательны.")); - if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) + if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); + "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id).")); var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, - r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken); + r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); - if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken)) + if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); @@ -152,9 +149,8 @@ public sealed class TemplateRouteStepService( entity.HeadType = r.HeadType; entity.Status = r.Status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; - entity.FieldName = r.FieldName; - entity.Operation = r.Operation; - entity.Value = r.Value; + entity.ConditionsLogic = r.ConditionsLogic; + ReplaceConditions(entity, r.Conditions); entity.SlaHours = r.SlaHours; entity.AskOverdueReason = r.AskOverdueReason; await db.SaveChangesAsync(cancellationToken); @@ -171,7 +167,8 @@ public sealed class TemplateRouteStepService( if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + var entity = await db.TemplateRouteSteps.Include(x => x.Conditions) + .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Шаг маршрута", id)); @@ -180,9 +177,8 @@ public sealed class TemplateRouteStepService( var projectRole = r.ProjectRole ?? entity.ProjectRole; var headType = r.HeadType ?? entity.HeadType; var status = r.Status ?? entity.Status; - var fieldName = r.FieldName ?? entity.FieldName; - var operation = r.Operation ?? entity.Operation; - var value = r.Value ?? entity.Value; + var conditions = r.Conditions ?? ToDtos(entity.Conditions); + var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic; if (status == RouteStepStatus.None) return Result.Fail(new ValidationError("Статус обязателен: Approval или Execution.")); @@ -195,22 +191,22 @@ public sealed class TemplateRouteStepService( return Result.Fail(new ValidationError( "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); - if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value)) + if (!TemplateRouteStepFields.AreConditionsValid(conditions)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + "В каждом условии (field_name, operation, value) все три поля обязательны.")); - if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) + if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) return Result.Fail(new ValidationError( - "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); + "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id).")); var duplicate = await HasDuplicateAsync( route.Id, executorEmail, position, projectRole, headType, status, - fieldName, operation, value, excludeId: entity.Id, cancellationToken); + conditions, conditionsLogic, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); - if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken)) + if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken)) return Result.Fail(new ConflictError( $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); @@ -221,9 +217,9 @@ public sealed class TemplateRouteStepService( entity.HeadType = headType; entity.Status = status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; - entity.FieldName = fieldName; - entity.Operation = operation; - entity.Value = value; + entity.ConditionsLogic = conditionsLogic; + if (r.Conditions is not null) + ReplaceConditions(entity, r.Conditions); // Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к // наследованию SLA от типа заявки можно через PUT. entity.SlaHours = r.SlaHours ?? entity.SlaHours; @@ -249,61 +245,108 @@ public sealed class TemplateRouteStepService( return Result.Ok(); } - private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) => - TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0; + private static bool RequiresList(IReadOnlyList? conditions) => conditions is { Count: > 0 }; + + private static List ToEntities(IReadOnlyList? conditions) => + (conditions ?? []).Select((c, i) => new TemplateRouteStepCondition + { + OrderNo = i, + FieldName = c.FieldName, + Operation = c.Operation, + Value = c.Value + }).ToList(); + + private static void ReplaceConditions(TemplateRouteStep entity, IReadOnlyList? conditions) + { + entity.Conditions.Clear(); + entity.Conditions.AddRange(ToEntities(conditions)); + } + + private static List ToDtos(IEnumerable conditions) => + conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList(); /// /// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом - /// условии появления (field_name/operation/value): шаги с разными условиями - /// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут, - /// а в один резолв — нет. + /// условии появления (набор условий + логика их объединения): шаги с разными + /// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они + /// попасть могут, а в один резолв — нет. Сравнение условий — в памяти + /// (см. ): кандидатов на дубликат в пределах + /// одного маршрута всегда мало, а сравнение списка условий целиком плохо + /// транслируется в SQL. /// - private Task HasDuplicateAsync( + private async Task HasDuplicateAsync( int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, - RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value, - int? excludeId, CancellationToken cancellationToken) => - db.TemplateRouteSteps.AnyAsync(x => - x.Id != excludeId && - x.RouteId == routeId && - x.ExecutorEmail == executorEmail && - x.Position == position && - x.ProjectRole == projectRole && - x. HeadType == headType && - x.Status == status && - x.FieldName == fieldName && - x.Operation == operation && - x.Value == value, - cancellationToken); + RouteStepStatus status, IReadOnlyList? conditions, ConditionsLogic conditionsLogic, + int? excludeId, CancellationToken cancellationToken) + { + var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions) + .Where(x => + x.Id != excludeId && + x.RouteId == routeId && + x.ExecutorEmail == executorEmail && + x.Position == position && + x.ProjectRole == projectRole && + x.HeadType == headType && + x.Status == status) + .ToListAsync(cancellationToken); + + return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic)); + } /// - /// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится - /// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для - /// DepartmentHead, единственный departmentId запроса (см. - /// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора. - /// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы - /// в одного и того же (или неотличимо какого) руководителя — например, «два - /// разных директора одной и той же компании» просто не существуют. В отличие - /// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь - /// достаточно совпадения HeadType — вне зависимости от статуса или остальных - /// полей шага. Исключение — условие появления шага: при разных условиях - /// в конкретный резолв попадёт только один из шагов, так что одного и того же - /// руководителя дважды в маршруте не окажется. + /// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors) + /// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости + /// инициатора, единственный departmentId запроса (для DepartmentHead, см. + /// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для + /// CompanyDirectors) не оставляют места для выбора. Для большинства значений это + /// один конкретный человек; для CompanyDirectors — один и тот же набор людей + /// (директор каждой компании инициатора), но тоже всегда один и тот же для + /// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым + /// HeadType всегда резолвились бы одинаково — например, «два разных директора + /// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync + /// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения + /// HeadType — вне зависимости от статуса или остальных полей шага. Исключение — + /// условие появления шага: при разных условиях в конкретный резолв попадёт только + /// один из шагов, так что дублирования исполнителей в маршруте не окажется. /// - private Task HasDuplicateHeadTypeAsync( - int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value, + private async Task HasDuplicateHeadTypeAsync( + int routeId, HeadType? headType, IReadOnlyList? conditions, ConditionsLogic conditionsLogic, int? excludeId, CancellationToken cancellationToken) { if (headType is null or HeadType.None) - return Task.FromResult(false); + return false; - return db.TemplateRouteSteps.AnyAsync(x => - x.Id != excludeId && - x.RouteId == routeId && - x.HeadType == headType && - x.FieldName == fieldName && - x.Operation == operation && - x.Value == value, - cancellationToken); + var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions) + .Where(x => x.Id != excludeId && x.RouteId == routeId && x.HeadType == headType) + .ToListAsync(cancellationToken); + + return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic)); + } + + /// + /// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок + /// влияет только на отображение в форме, не на вычисление) и, если условий два и + /// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не + /// влияет, поэтому не сравнивается. + /// + private static bool ConditionsEqual( + IReadOnlyList a, ConditionsLogic aLogic, + IReadOnlyList? b, ConditionsLogic bLogic) + { + var bList = b ?? []; + if (a.Count != bList.Count) + return false; + if (a.Count > 1 && aLogic != bLogic) + return false; + + static IEnumerable<(string FieldName, ComparisonOperator Operation, string Value)> Normalize( + IReadOnlyList c) => + c.Select(x => (x.FieldName, x.Operation, x.Value)) + .OrderBy(x => x.FieldName, StringComparer.Ordinal) + .ThenBy(x => x.Operation) + .ThenBy(x => x.Value, StringComparer.Ordinal); + + return Normalize(a).SequenceEqual(Normalize(bList)); } private async Task ToDtoWithWarningAsync( @@ -323,5 +366,5 @@ public sealed class TemplateRouteStepService( private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers, - e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason); + ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 577edf1..2a5d42e 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -12,8 +12,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); RuleFor(x => x) - .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) - .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); + .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions)) + .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны."); // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // ноль допустим — это осознанное «напоминать сразу». RuleFor(x => x.SlaHours) @@ -32,8 +32,8 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); RuleFor(x => x) - .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) - .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); + .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions)) + .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны."); // Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // ноль допустим — это осознанное «напоминать сразу». RuleFor(x => x.SlaHours) @@ -51,14 +51,14 @@ internal static class TemplateRouteStepFields (projectRole is null or ProjectRole.None ? 0 : 1) + (headType is null or HeadType.None ? 0 : 1); - public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) => - (string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) + - (operation is null or ComparisonOperator.None ? 0 : 1) + - (string.IsNullOrWhiteSpace(value) ? 0 : 1); - - public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value) - { - var count = CountConditionFilled(fieldName, operation, value); - return count == 0 || count == 3; - } + /// + /// Условие с пустым field_name/value или operation=None — половинчатое, а не + /// «выключенное» (выключается вовсе не добавляя условие в список), поэтому + /// такое отдельное условие недопустимо, даже если остальные условия шага корректны. + /// + public static bool AreConditionsValid(IReadOnlyList? conditions) => + conditions is null || conditions.All(c => + !string.IsNullOrWhiteSpace(c.FieldName) && + c.Operation != ComparisonOperator.None && + !string.IsNullOrWhiteSpace(c.Value)); } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 1aba714..7632e7d 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -13,6 +13,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; /// /// Спрашивать ли причину просрочки — так же с уже применённым наследованием. /// +/// +/// HeadType шага (Chief/DirectionHead/DirectorTansu/ManagingDirectorTansu/DepartmentHead), +/// если исполнитель задан типом руководителя, иначе null. В отличие от +/// (реальная должность резолвнутого сотрудника из справочника api.tnsu.kz — свободный +/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить, +/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом. +/// public sealed record ResolvedRouteStepDto( int Id, int OrderNo, @@ -21,7 +28,8 @@ public sealed record ResolvedRouteStepDto( bool AllowReassignToOtherApprovers, string? Warning = null, int? SlaHours = null, - bool? AskOverdueReason = null); + bool? AskOverdueReason = null, + HeadType? PositionCode = null); public sealed record ResolveTemplateRouteResultDto( int RouteId, diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 2b907b3..c49fcde 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -142,18 +142,30 @@ public sealed class TemplateRouteService( if (requestType is null) return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); - var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); - if (employee is null) + // /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, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken) - ?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken); + 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( @@ -189,7 +201,7 @@ public sealed class TemplateRouteService( var matchingSteps = route.Steps .Where(s => s.Status == request.Status) - .Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing)) + .Where(s => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing)) .OrderBy(s => s.OrderNo) .ToList(); @@ -200,17 +212,51 @@ public sealed class TemplateRouteService( // соответствует порядку задач, а не порядку завершения), поэтому // OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ // дубликат считается первым) продолжают работать так же, как при - // последовательном foreach. - var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync( - step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, + // последовательном 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 collapsedSteps = CollapseByExecutorEmail(steps); + var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList()); + var collapsedSteps = CollapseByExecutorEmail(flattenedSteps); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } + /// + /// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором + /// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU + /// Construction. Оба шага в маршруте законны сами по себе, но если так, из + /// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом + /// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail): + /// тот считает разные PositionCode у одного email конфликтом и пишет + /// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто + /// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним + /// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors + /// оставляет его на своём месте — в конце. + /// + private static IReadOnlyList RemoveCompanyDirectorsDuplicatedByDirectorTansu( + IReadOnlyList steps) + { + var directorTansuEmails = steps + .Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail)) + .Select(s => s.ExecutorEmail!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (directorTansuEmails.Count == 0) + return steps; + + return steps + .Where(s => s.PositionCode != HeadType.CompanyDirectors + || string.IsNullOrWhiteSpace(s.ExecutorEmail) + || !directorTansuEmails.Contains(s.ExecutorEmail)) + .ToList(); + } + /// /// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное /// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела @@ -220,7 +266,7 @@ public sealed class TemplateRouteService( private async Task FindRouteAsync( int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) => await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) + .Include(x => x.Steps).ThenInclude(s => s.Conditions) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && @@ -228,7 +274,7 @@ public sealed class TemplateRouteService( x.DepartmentId == departmentId) .FirstOrDefaultAsync(cancellationToken) ?? await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) + .Include(x => x.Steps).ThenInclude(s => s.Conditions) .Where(x => x.RequestTypeId == requestTypeId && x.CompanyId == companyId && @@ -252,12 +298,104 @@ public sealed class TemplateRouteService( /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// - private async Task ResolveStepAsync( - TemplateRouteStep step, string initiatorEmail, string initiatorCompany, + /// + /// Название должности директора для конкретной компании — по умолчанию «Директор», + /// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»), + /// поэтому для 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; @@ -318,47 +456,33 @@ public sealed class TemplateRouteService( 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); + return [await BuildDtoAsync(resolvedEmail, warning)]; } /// - /// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе. - /// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля - /// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный - /// сценарий, и отсутствие значения означает, что условие не выполняется. + /// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий + /// применяется всегда. Несколько условий объединяются через : + /// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано + /// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы + /// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется. /// - private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary fieldValues) + private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary fieldValues) { - if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None) + if (step.Conditions.Count == 0) return true; - if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) + 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 = step.Value; - return step.Operation switch + var expected = condition.Value; + return condition.Operation switch { ComparisonOperator.Equal => IsEqual(actual, expected), ComparisonOperator.NotEqual => !IsEqual(actual, expected), @@ -399,14 +523,14 @@ public sealed class TemplateRouteService( "поэтому шаг включён в маршрут, хотя условие могло и не выполниться."; /// - /// Условие шага завязано на IsManager, но вычислить его не из чего: должность - /// в заявке не передана. Такой шаг попадает в результат вопреки условию и с - /// предупреждением — см. ResolveAsync. + /// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность + /// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с + /// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага: + /// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен. /// private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) => positionMissing && - string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) && - step.Operation is not (null or ComparisonOperator.None); + step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase)); /// /// Должность — свободный текст без структуры (нет отдельного флага «это @@ -423,17 +547,50 @@ public sealed class TemplateRouteService( !string.IsNullOrWhiteSpace(position) && ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + /// + /// Один и тот же исполнитель, полученный из разных шагов, в результате должен + /// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode + /// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки + /// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он + /// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две + /// несовместимые роли шага, например Chief и CompanyDirectors одновременно) — + /// это не должно молча выбрать одну из них без следа: добавляем предупреждение + /// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности + /// хуже, чем показать чуть неточный PositionCode с пояснением. + /// private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) { - var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); + var indexByEmail = new Dictionary(StringComparer.OrdinalIgnoreCase); var result = new List(); foreach (var step in steps) { - if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail)) + if (string.IsNullOrWhiteSpace(step.ExecutorEmail)) + { + result.Add(step); continue; + } - result.Add(step); + 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; diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 665bd01..0478b4d 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -31,9 +31,11 @@ public class TemplateRouteStep /// public bool? AskOverdueReason { get; set; } - public string? FieldName { get; set; } - public ComparisonOperator? Operation { get; set; } - public string? Value { get; set; } + /// Как объединяются между собой — не действует, если условий меньше двух. + public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And; + + /// Условия появления шага. Пусто — шаг применяется всегда. + public List Conditions { get; set; } = []; public TemplateRoute? Route { get; set; } } diff --git a/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs new file mode 100644 index 0000000..2683ff2 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStepCondition.cs @@ -0,0 +1,24 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Domain.Entities; + +/// +/// Одно условие появления шага (см. ) — +/// сравнение значения поля заявки () с +/// через . Несколько условий одного шага объединяются +/// через (И/ИЛИ). +/// +public class TemplateRouteStepCondition +{ + public int Id { get; set; } + + public int StepId { get; set; } + public TemplateRouteStep? Step { get; set; } + + /// Порядок отображения в форме — на вычисление условия не влияет. + public int OrderNo { get; set; } + + public string FieldName { get; set; } = string.Empty; + public ComparisonOperator Operation { get; set; } + public string Value { get; set; } = string.Empty; +} diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs new file mode 100644 index 0000000..1f37402 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs @@ -0,0 +1,14 @@ +namespace Tnsu.ApprovalRoute.Domain.Enums; + +/// +/// Как объединяются условия шага () +/// между собой, если их несколько. Не действует, если условие одно или их нет вовсе. +/// +public enum ConditionsLogic +{ + /// Шаг появляется, только если выполняются ВСЕ условия. + And, + + /// Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие. + Or +} diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs index 6dacd1d..1ea0e54 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -15,5 +15,15 @@ public enum HeadType /// подчинённости самого инициатора. В маршруте не может быть больше одного /// шага с этим типом (см. TemplateRouteStepService). /// - DepartmentHead + DepartmentHead, + + /// + /// Директора ВСЕХ компаний, в которых числится инициатор (сотрудник может + /// работать в нескольких компаниях — /Employee/ByEmail теперь возвращает + /// массив). В отличие от остальных значений этого enum'а, один такой шаг + /// резолвится в НЕСКОЛЬКО строк результата — по одной на каждую компанию + /// инициатора, все с одним и тем же OrderNo (то есть параллельно, см. + /// TemplateRouteService.ResolveStepAsync). + /// + CompanyDirectors } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index edea59c..733319c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -10,6 +10,7 @@ public class ApprovalRouteDbContext(DbContextOptions opt public DbSet RequestListPartitions => Set(); public DbSet TemplateRoutes => Set(); public DbSet TemplateRouteSteps => Set(); + public DbSet TemplateRouteStepConditions => Set(); public DbSet Settings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -61,11 +62,19 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion().HasMaxLength(32); e.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); - e.Property(x => x.FieldName).HasMaxLength(128); - e.Property(x => x.Operation).HasConversion().HasMaxLength(32); - e.Property(x => x.Value).HasMaxLength(256); + e.Property(x => x.ConditionsLogic).HasConversion().HasMaxLength(16).IsRequired(); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasIndex(x => new { x.RouteId, x.OrderNo }); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.FieldName).HasMaxLength(128).IsRequired(); + e.Property(x => x.Operation).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.Value).HasMaxLength(256).IsRequired(); + e.HasOne(x => x.Step).WithMany(x => x.Conditions).HasForeignKey(x => x.StepId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(x => new { x.StepId, x.OrderNo }); + }); } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs new file mode 100644 index 0000000..4aed11e --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.Designer.cs @@ -0,0 +1,366 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApprovalRouteDbContext))] + [Migration("20260825044822_AddMultipleStepConditions")] + partial class AddMultipleStepConditions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ListId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("Number") + .HasColumnType("integer") + .HasColumnName("number"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_request_list_partitions"); + + b.HasIndex("RequestTypeId", "Number") + .IsUnique() + .HasDatabaseName("ix_request_list_partitions_request_type_id_number"); + + b.ToTable("request_list_partitions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ListId") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("list_id"); + + b.Property("ListPartitioningStartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("list_partitioning_started_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + b.ToTable("request_types", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.HasKey("Id") + .HasName("pk_template_routes"); + + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + + b.ToTable("template_routes", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("AskOverdueReason") + .HasColumnType("boolean") + .HasColumnName("ask_overdue_reason"); + + b.Property("ConditionsLogic") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("conditions_logic"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("SlaHours") + .HasColumnType("integer") + .HasColumnName("sla_hours"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_template_route_steps"); + + b.HasIndex("RouteId", "OrderNo") + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); + + b.ToTable("template_route_steps", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("StepId") + .HasColumnType("integer") + .HasColumnName("step_id"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_step_conditions"); + + b.HasIndex("StepId", "OrderNo") + .HasDatabaseName("ix_template_route_step_conditions_step_id_order_no"); + + b.ToTable("template_route_step_conditions", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => + { + b.Property("OptionName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("option_name"); + + b.Property("FileData") + .HasColumnType("bytea") + .HasColumnName("file_data"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("OptionName") + .HasName("pk_settings"); + + b.ToTable("settings", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("ListPartitions") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_request_list_partitions_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Routes") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_routes_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") + .WithMany("Steps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step") + .WithMany("Conditions") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id"); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Navigation("ListPartitions"); + + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Navigation("Conditions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs new file mode 100644 index 0000000..45855b7 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260825044822_AddMultipleStepConditions.cs @@ -0,0 +1,120 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddMultipleStepConditions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "conditions_logic", + table: "template_route_steps", + type: "character varying(16)", + maxLength: 16, + nullable: false, + defaultValue: "And"); + + migrationBuilder.CreateTable( + name: "template_route_step_conditions", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + step_id = table.Column(type: "integer", nullable: false), + order_no = table.Column(type: "integer", nullable: false), + field_name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + operation = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + value = table.Column(type: "character varying(256)", maxLength: 256, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_template_route_step_conditions", x => x.id); + table.ForeignKey( + name: "fk_template_route_step_conditions_template_route_steps_step_id", + column: x => x.step_id, + principalTable: "template_route_steps", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_template_route_step_conditions_step_id_order_no", + table: "template_route_step_conditions", + columns: new[] { "step_id", "order_no" }); + + // Существующее одиночное условие шага (field_name/operation/value) переносим + // как первое (и единственное) условие в новую таблицу — до удаления старых + // колонок, иначе уже настроенные в деве/проде условия шагов молча пропадут. + migrationBuilder.Sql( + """ + INSERT INTO template_route_step_conditions (step_id, order_no, field_name, operation, value) + SELECT id, 0, field_name, operation, value + FROM template_route_steps + WHERE field_name IS NOT NULL AND operation IS NOT NULL AND value IS NOT NULL; + """); + + migrationBuilder.DropColumn( + name: "field_name", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "operation", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "value", + table: "template_route_steps"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "field_name", + table: "template_route_steps", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "operation", + table: "template_route_steps", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "value", + table: "template_route_steps", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + // Откат теряет условия сверх первого (по order_no) — старая схема хранила + // только одно условие на шаг, второе и далее девать некуда. + migrationBuilder.Sql( + """ + UPDATE template_route_steps s + SET field_name = c.field_name, operation = c.operation, value = c.value + FROM ( + SELECT DISTINCT ON (step_id) step_id, field_name, operation, value + FROM template_route_step_conditions + ORDER BY step_id, order_no + ) c + WHERE c.step_id = s.id; + """); + + migrationBuilder.DropTable( + name: "template_route_step_conditions"); + + migrationBuilder.DropColumn( + name: "conditions_logic", + table: "template_route_steps"); + } + } +} diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 1e95a34..520d7a5 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -175,26 +175,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("ask_overdue_reason"); + b.Property("ConditionsLogic") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("conditions_logic"); + b.Property("ExecutorEmail") .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("executor_email"); - b.Property("FieldName") - .HasMaxLength(128) - .HasColumnType("character varying(128)") - .HasColumnName("field_name"); - b.Property("HeadType") .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("head_type"); - b.Property("Operation") - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("operation"); - b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); @@ -223,11 +219,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("status"); - b.Property("Value") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("value"); - b.HasKey("Id") .HasName("pk_template_route_steps"); @@ -237,6 +228,50 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("template_route_steps", (string)null); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("StepId") + .HasColumnType("integer") + .HasColumnName("step_id"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_step_conditions"); + + b.HasIndex("StepId", "OrderNo") + .HasDatabaseName("ix_template_route_step_conditions_step_id_order_no"); + + b.ToTable("template_route_step_conditions", (string)null); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => { b.Property("OptionName") @@ -294,6 +329,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("Route"); }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step") + .WithMany("Conditions") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id"); + + b.Navigation("Step"); + }); + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { b.Navigation("ListPartitions"); @@ -305,6 +352,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { b.Navigation("Steps"); }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Navigation("Conditions"); + }); #pragma warning restore 612, 618 } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 785e246..6f1ade9 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -17,7 +17,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); - public Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) => + public Task> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) => GetOrAddAsync( $"emp:email:{email.ToLowerInvariant()}", () => inner.GetEmployeeByEmailAsync(email, cancellationToken)); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 40c5b9c..412fabf 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -12,14 +12,16 @@ public sealed class HttpTnsuPortalClient( { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; - public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) + public async Task> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) { - var employee = await GetAsync( + var employees = await GetAsync>( $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken); - if (employee is null) - return null; + if (employees is null) + return []; - return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position); + return employees + .Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position)) + .ToList(); } public async Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 2821d9d..731a898 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -2,7 +2,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; public interface ITnsuPortalClient { - Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); + /// + /// Все записи сотрудника по email — /Employee/ByEmail отдаёт массив: сотрудник может + /// числиться в нескольких компаниях одновременно (один Id/Fio/Email, разные Company/ + /// CompanyId/Section/DepartmentId/Position на каждую). Пусто, если не найден вовсе. + /// + Task> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index d3167d6..0a69447 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -27,7 +27,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); @@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -182,6 +182,8 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny())) .ReturnsAsync("dup@tnsu.kz"); @@ -283,15 +285,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); + portalClient + .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny())) + .ReturnsAsync("same@tnsu.kz"); + portalClient + .Setup(x => x.GetManagerAsync(It.IsAny(), "directionHead", It.IsAny())) + .ReturnsAsync("same@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Collapse conflicting position code request", "/collapse-conflicting-position-code", null, null, "General"), + CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null), + CancellationToken.None); + + // Action + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("same@tnsu.kz"); + resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief); + var warning = resolved.Value.Steps[0].Warning; + warning.ShouldNotBeNull(); + warning.ShouldContain("Chief"); + warning.ShouldContain("DirectionHead"); + } + [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")] public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor() { @@ -320,7 +377,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action — departmentId в запросе не передан @@ -345,7 +402,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny())) .ReturnsAsync("head-of-department@tnsu.kz"); @@ -362,7 +419,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny())) .ReturnsAsync((string?)null); @@ -402,7 +459,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), CancellationToken.None); // Action @@ -458,11 +515,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -489,7 +546,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), CancellationToken.None); // Action — fieldValues не переданы вовсе @@ -514,7 +571,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"), + new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }), CancellationToken.None); // Action @@ -548,7 +605,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", operation, expected), + new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }), CancellationToken.None); // Action @@ -562,6 +619,47 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "multi-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, + new[] + { + new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"), + new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") + }, + logic), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Amount", amount), new ResolveFieldValueDto("Priority", priority)]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0); + } + [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { @@ -574,7 +672,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -595,7 +693,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer") }); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович")); @@ -612,7 +710,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position") }); portalClient .Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny())) .ReturnsAsync("chief@tnsu.kz"); @@ -651,7 +749,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position")); + .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position") }); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); @@ -686,7 +785,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture x.GetManagerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact(DisplayName = "Резолв шага с типом руководителя CompanyDirectors резолвится в директора каждой компании инициатора, все с одним OrderNo")] + public async Task ResolveAsync_ByHeadTypeCompanyDirectors_ResolvesOneRowPerCompany() + { + // Arrange — инициатор числится в двух компаниях; для одной из них (KazPromService) + // должность директора захардкожена как «Главный механик», а не «Директор». + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), + new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности") + }); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS")); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("CompanyDirectors request", "/company-directors-request", null, null, "General"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), + CancellationToken.None); + + // Action + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.Count.ShouldBe(2); + resolved.Value.Steps.ShouldAllBe(s => s.OrderNo == 5); + resolved.Value.Steps.ShouldAllBe(s => s.PositionCode == HeadType.CompanyDirectors); + resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); + resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")] + public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() + { + // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) + // и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) — + // один и тот же человек с одним и тем же email. + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), + new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности") + }); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny())) + .ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS")); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("CompanyDirectors overlaps DirectorTansu request", "/company-directors-overlaps-director-tansu", null, null, "General"), + CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), + CancellationToken.None); + + // Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail, + // поэтому HeadType.DirectorTansu резолвится в дефолт из TnsuPortalOptions + // ("director.tansu@tnsu.kz") — тот же email, что вернул GetEmployeeByPositionAsync + // для ТОО TANSU Construction. + var resolved = await routeService.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert — вместо трёх строк (2 CompanyDirectors + 1 DirectorTansu) остаётся + // две: KazPromService-директор и Директор Tansu, без предупреждения о конфликте. + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.Count.ShouldBe(2); + var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); + tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); + tansuStep.OrderNo.ShouldBe(10); + tansuStep.Warning.ShouldBeNull(); + resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors); + } + [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")] public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning() { @@ -713,7 +921,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -742,7 +950,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action @@ -771,7 +979,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, null, null), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null), CancellationToken.None); // Action diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index ab4241e..a6aa592 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture ({ value: v, label: routeStepStatusLabels[v] })); @@ -86,6 +96,8 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator) .filter(v => v !== ComparisonOperator.None) .map(v => ({ value: v, label: comparisonOperatorLabels[v] })); +const CONDITIONS_LOGIC_OPTIONS = Object.values(ConditionsLogic).map(v => ({ value: v, label: conditionsLogicLabels[v] })); + /** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */ const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan]; @@ -118,9 +130,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number headType: HeadType.Chief, allowReassignToOtherApprovers: false, hasCondition: false, - fieldName: '', - operation: ComparisonOperator.GreaterThan, - value: '', + conditions: [EMPTY_CONDITION], + conditionsLogic: ConditionsLogic.And, slaHours: '', askOverdueReason: '' }; @@ -134,10 +145,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers, - hasCondition: Boolean(editing.fieldName), - fieldName: editing.fieldName ?? '', - operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan, - value: editing.value ?? '', + hasCondition: editing.conditions.length > 0, + conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION], + conditionsLogic: editing.conditionsLogic, slaHours: slaHoursToInput(editing.slaHours), askOverdueReason: boolToTriState(editing.askOverdueReason) }; @@ -162,59 +172,61 @@ export const RouteStepFormModal: React.FC = ({ const hasListId = Boolean(requestType?.listId); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); - const fieldNameOptions = React.useMemo(() => { + // currentValue — значение конкретной строки условия: если оно не входит в + // подгруженные поля списка (набрано до появления поля в списке или после его + // удаления), добавляем его отдельным пунктом, чтобы не сбрасывать выбор молча. + const fieldNameOptionsFor = (currentValue: string): { value: string; label: string }[] => { 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.push({ value: form.fieldName, label: form.fieldName }); + if (currentValue && !options.some(o => o.value === currentValue)) { + options.push({ value: currentValue, label: currentValue }); } 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 fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField - ? 'bool' - : !selectedField - ? 'unknown' - : isNumericFieldType(selectedField.typeAsString) - ? 'numeric' - : 'text'; - const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown'; + const fieldKindOf = (fieldName: string): 'bool' | 'numeric' | 'text' | 'unknown' => { + if (fieldName === IS_MANAGER_FIELD) return 'bool'; + const field = sharePointFields.items.find(f => f.internalName === fieldName); + if (!field) return 'unknown'; + return isNumericFieldType(field.typeAsString) ? 'numeric' : 'text'; + }; + const isNumericFieldKind = (kind: ReturnType): boolean => kind === 'numeric' || kind === 'unknown'; // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше». // Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем. - const operationOptions = React.useMemo(() => OPERATION_OPTIONS.filter(o => { - if (fieldKind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual; - if (fieldKind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0; - if (fieldKind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0; - return true; - }), [fieldKind]); + const operationOptionsFor = (kind: ReturnType): typeof OPERATION_OPTIONS => + OPERATION_OPTIONS.filter(o => { + if (kind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual; + if (kind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0; + if (kind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0; + return true; + }); - // Оператор, недопустимый для выбранной колонки (например, «больше» после смены - // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда. - const isOperationAllowed = operationOptions.some(o => o.value === form.operation); - React.useEffect(() => { - if (!isOperationAllowed) { - setForm(f => ({ ...f, operation: ComparisonOperator.Equal })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOperationAllowed]); - - // 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]); + const updateCondition = (index: number, patch: Partial): void => { + setForm(f => ({ + ...f, + conditions: f.conditions.map((c, i) => { + if (i !== index) return c; + const next = { ...c, ...patch }; + // Оператор, недопустимый для новой колонки (например, «больше» после смены + // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда. + const kind = fieldKindOf(next.fieldName); + if (!operationOptionsFor(kind).some(o => o.value === next.operation)) next.operation = ComparisonOperator.Equal; + // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, + // а не произвольным текстом, который пользователь мог набрать для другого field_name. + if (kind === 'bool' && next.value !== 'true' && next.value !== 'false') next.value = 'true'; + return next; + }) + })); + }; + const addCondition = (): void => setForm(f => ({ ...f, conditions: [...f.conditions, EMPTY_CONDITION] })); + const removeCondition = (index: number): void => + setForm(f => ({ ...f, conditions: f.conditions.filter((_, i) => i !== index) })); // Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ // совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо @@ -336,13 +348,18 @@ export const RouteStepFormModal: React.FC = ({ submit.fail(texts.routeSteps.validationPosition); return; } - if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) { - submit.fail(texts.routeSteps.validationCondition); - return; - } - if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) { - submit.fail(texts.routeSteps.validationConditionNumber); - return; + if (form.hasCondition) { + for (const c of form.conditions) { + if (!c.fieldName.trim() || !c.value.trim()) { + submit.fail(texts.routeSteps.validationCondition); + return; + } + const kind = fieldKindOf(c.fieldName); + if (isNumericFieldKind(kind) && !Number.isFinite(Number(c.value.trim()))) { + submit.fail(texts.routeSteps.validationConditionNumber(c.fieldName)); + return; + } + } } if (!isValidSlaHours(form.slaHours)) { submit.fail(texts.routeSteps.validationSlaHours); @@ -357,9 +374,10 @@ export const RouteStepFormModal: React.FC = ({ position: form.kind === 'position' ? form.position.trim() : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null, headType: form.kind === 'headType' ? form.headType : null, - fieldName: form.hasCondition ? form.fieldName.trim() : null, - operation: form.hasCondition ? form.operation : null, - value: form.hasCondition ? form.value.trim() : null, + conditions: form.hasCondition + ? form.conditions.map(c => ({ fieldName: c.fieldName.trim(), operation: c.operation, value: c.value.trim() })) + : null, + conditionsLogic: form.conditionsLogic, slaHours: parseSlaHours(form.slaHours), askOverdueReason: triStateToBool(form.askOverdueReason) }; @@ -495,54 +513,75 @@ export const RouteStepFormModal: React.FC = ({ /> - {form.hasCondition && ( - <> - - setForm({ ...form, operation: e.target.value as ComparisonOperator })} - /> - - - {isManagerField ? ( - setForm({ ...form, conditionsLogic: e.target.value as ConditionsLogic })} + /> + + )} + + {form.conditions.map((condition, index) => { + const kind = fieldKindOf(condition.fieldName); + const fieldNameOptions = fieldNameOptionsFor(condition.fieldName); + return ( + // eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом +
+ updateCondition(index, { operation: e.target.value as ComparisonOperator })} + /> + {kind === 'bool' ? ( +