Несколько условий шага, объединяемых через И/ИЛИ

Раньше шаг маршрута мог нести ровно одно условие (field_name/operation/
value). Теперь условий может быть сколько угодно, а их объединение задаётся
явно — ConditionsLogic.And (все условия) или .Or (любое из условий).

Бэкенд:
- Новая сущность TemplateRouteStepCondition (дочерняя таблица
  template_route_step_conditions, FK на шаг, cascade delete) вместо трёх
  колонок на TemplateRouteStep. Миграция AddMultipleStepConditions переносит
  существующие условия шагов в новую таблицу перед удалением старых колонок,
  чтобы не потерять уже настроенные в деве/проде условия.
- TemplateRouteService.MatchesConditions: несколько условий шага объединяются
  по ConditionsLogic (Any/All); HasUnevaluatableManagerCondition проверяет
  условие на IsManager в списке условий, а не единственное поле.
- TemplateRouteStepService: дубликаты шагов теперь сравниваются по всему
  набору условий (без учёта порядка) и, если условий два и более, по логике
  их объединения — сравнение в памяти, а не в SQL, кандидатов на дубликат
  в пределах маршрута всегда мало.
- Валидация условия: раньше «все три поля или ни одного», теперь — в каждом
  элементе списка все три поля обязательны.

Фронтенд:
- RouteStepFormModal: условие — динамический список строк (добавить/удалить),
  у каждой строки свой набор допустимых операторов по типу колонки; селектор
  И/ИЛИ появляется, когда условий два и больше.
- describeStepCondition в таблице шагов склеивает все условия через « И »/« ИЛИ ».

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-08-25 09:56:36 +05:00
parent e4a6cb8167
commit 10c3057eff
21 changed files with 1201 additions and 346 deletions
@@ -7,6 +7,9 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец // (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
// записей, чтобы не ломать существующие позиционные вызовы. // записей, чтобы не ломать существующие позиционные вызовы.
/// <summary>Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition.</summary>
public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value);
public sealed record TemplateRouteStepDto( public sealed record TemplateRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
@@ -17,9 +20,8 @@ public sealed record TemplateRouteStepDto(
RouteStepStatus Status, RouteStepStatus Status,
int RouteId, int RouteId,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto> Conditions,
ComparisonOperator? Operation, ConditionsLogic ConditionsLogic,
string? Value,
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -32,9 +34,8 @@ public sealed record CreateTemplateRouteStepRequest(
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto>? Conditions,
ComparisonOperator? Operation, ConditionsLogic ConditionsLogic = ConditionsLogic.And,
string? Value,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -46,12 +47,16 @@ public sealed record UpdateTemplateRouteStepRequest(
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto>? Conditions,
ComparisonOperator? Operation, ConditionsLogic ConditionsLogic = ConditionsLogic.And,
string? Value,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
/// <summary>
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше
/// для FieldName) — очистка условий делается через PUT с Conditions: [].
/// </summary>
public sealed record PatchTemplateRouteStepRequest( public sealed record PatchTemplateRouteStepRequest(
int? OrderNo, int? OrderNo,
string? ExecutorEmail, string? ExecutorEmail,
@@ -60,8 +65,7 @@ public sealed record PatchTemplateRouteStepRequest(
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus? Status, RouteStepStatus? Status,
bool? AllowReassignToOtherApprovers, bool? AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto>? Conditions,
ComparisonOperator? Operation, ConditionsLogic? ConditionsLogic = null,
string? Value,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -15,14 +15,12 @@ public sealed class TemplateRouteStepService(
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{ {
var items = await db.TemplateRouteSteps.AsNoTracking() var items = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.Where(x => x.RouteId == routeId) .Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo) .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
} }
/// <summary> /// <summary>
@@ -38,19 +36,18 @@ public sealed class TemplateRouteStepService(
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>()); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
var items = await db.TemplateRouteSteps.AsNoTracking() var items = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail)) .Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo) .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
} }
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken) public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRouteSteps.AsNoTracking() var entity = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
return entity is null return entity is null
@@ -68,22 +65,22 @@ public sealed class TemplateRouteStepService(
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(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<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, 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) if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(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<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
@@ -97,9 +94,8 @@ public sealed class TemplateRouteStepService(
HeadType = r.HeadType, HeadType = r.HeadType,
Status = r.Status, Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
FieldName = r.FieldName, ConditionsLogic = r.ConditionsLogic,
Operation = r.Operation, Conditions = ToEntities(r.Conditions),
Value = r.Value,
SlaHours = r.SlaHours, SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason AskOverdueReason = r.AskOverdueReason
}; };
@@ -118,7 +114,8 @@ public sealed class TemplateRouteStepService(
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(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) if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
@@ -126,22 +123,22 @@ public sealed class TemplateRouteStepService(
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(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<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, 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) if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(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<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
@@ -152,9 +149,8 @@ public sealed class TemplateRouteStepService(
entity.HeadType = r.HeadType; entity.HeadType = r.HeadType;
entity.Status = r.Status; entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
entity.FieldName = r.FieldName; entity.ConditionsLogic = r.ConditionsLogic;
entity.Operation = r.Operation; ReplaceConditions(entity, r.Conditions);
entity.Value = r.Value;
entity.SlaHours = r.SlaHours; entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason; entity.AskOverdueReason = r.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -171,7 +167,8 @@ public sealed class TemplateRouteStepService(
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(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) if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
@@ -180,9 +177,8 @@ public sealed class TemplateRouteStepService(
var projectRole = r.ProjectRole ?? entity.ProjectRole; var projectRole = r.ProjectRole ?? entity.ProjectRole;
var headType = r.HeadType ?? entity.HeadType; var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status; var status = r.Status ?? entity.Status;
var fieldName = r.FieldName ?? entity.FieldName; var conditions = r.Conditions ?? ToDtos(entity.Conditions);
var operation = r.Operation ?? entity.Operation; var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic;
var value = r.Value ?? entity.Value;
if (status == RouteStepStatus.None) if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution.")); return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
@@ -195,22 +191,22 @@ public sealed class TemplateRouteStepService(
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value)) if (!TemplateRouteStepFields.AreConditionsValid(conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(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<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status, route.Id, executorEmail, position, projectRole, headType, status,
fieldName, operation, value, excludeId: entity.Id, cancellationToken); conditions, conditionsLogic, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(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<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
@@ -221,9 +217,9 @@ public sealed class TemplateRouteStepService(
entity.HeadType = headType; entity.HeadType = headType;
entity.Status = status; entity.Status = status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
entity.FieldName = fieldName; entity.ConditionsLogic = conditionsLogic;
entity.Operation = operation; if (r.Conditions is not null)
entity.Value = value; ReplaceConditions(entity, r.Conditions);
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к // Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
// наследованию SLA от типа заявки можно через PUT. // наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours; entity.SlaHours = r.SlaHours ?? entity.SlaHours;
@@ -249,31 +245,53 @@ public sealed class TemplateRouteStepService(
return Result.Ok(); return Result.Ok();
} }
private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) => private static bool RequiresList(IReadOnlyList<RouteStepConditionDto>? conditions) => conditions is { Count: > 0 };
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
private static List<TemplateRouteStepCondition> ToEntities(IReadOnlyList<RouteStepConditionDto>? 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<RouteStepConditionDto>? conditions)
{
entity.Conditions.Clear();
entity.Conditions.AddRange(ToEntities(conditions));
}
private static List<RouteStepConditionDto> ToDtos(IEnumerable<TemplateRouteStepCondition> conditions) =>
conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList();
/// <summary> /// <summary>
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом /// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
/// условии появления (field_name/operation/value): шаги с разными условиями /// условии появления (набор условий + логика их объединения): шаги с разными
/// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут, /// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они
/// а в один резолв — нет. /// попасть могут, а в один резолв — нет. Сравнение условий — в памяти
/// (см. <see cref="ConditionsEqual"/>): кандидатов на дубликат в пределах
/// одного маршрута всегда мало, а сравнение списка условий целиком плохо
/// транслируется в SQL.
/// </summary> /// </summary>
private Task<bool> HasDuplicateAsync( private async Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value, RouteStepStatus status, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
int? excludeId, CancellationToken cancellationToken) => int? excludeId, CancellationToken cancellationToken)
db.TemplateRouteSteps.AnyAsync(x => {
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
.Where(x =>
x.Id != excludeId && x.Id != excludeId &&
x.RouteId == routeId && x.RouteId == routeId &&
x.ExecutorEmail == executorEmail && x.ExecutorEmail == executorEmail &&
x.Position == position && x.Position == position &&
x.ProjectRole == projectRole && x.ProjectRole == projectRole &&
x. HeadType == headType && x.HeadType == headType &&
x.Status == status && x.Status == status)
x.FieldName == fieldName && .ToListAsync(cancellationToken);
x.Operation == operation &&
x.Value == value, return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
cancellationToken); }
/// <summary> /// <summary>
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится /// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
@@ -289,21 +307,44 @@ public sealed class TemplateRouteStepService(
/// в конкретный резолв попадёт только один из шагов, так что одного и того же /// в конкретный резолв попадёт только один из шагов, так что одного и того же
/// руководителя дважды в маршруте не окажется. /// руководителя дважды в маршруте не окажется.
/// </summary> /// </summary>
private Task<bool> HasDuplicateHeadTypeAsync( private async Task<bool> HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value, int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
int? excludeId, CancellationToken cancellationToken) int? excludeId, CancellationToken cancellationToken)
{ {
if (headType is null or HeadType.None) if (headType is null or HeadType.None)
return Task.FromResult(false); return false;
return db.TemplateRouteSteps.AnyAsync(x => var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
x.Id != excludeId && .Where(x => x.Id != excludeId && x.RouteId == routeId && x.HeadType == headType)
x.RouteId == routeId && .ToListAsync(cancellationToken);
x.HeadType == headType &&
x.FieldName == fieldName && return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
x.Operation == operation && }
x.Value == value,
cancellationToken); /// <summary>
/// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок
/// влияет только на отображение в форме, не на вычисление) и, если условий два и
/// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не
/// влияет, поэтому не сравнивается.
/// </summary>
private static bool ConditionsEqual(
IReadOnlyList<RouteStepConditionDto> a, ConditionsLogic aLogic,
IReadOnlyList<RouteStepConditionDto>? 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<RouteStepConditionDto> 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<TemplateRouteStepDto> ToDtoWithWarningAsync( private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
@@ -323,5 +364,5 @@ public sealed class TemplateRouteStepService(
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( 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.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);
} }
@@ -12,8 +12,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу». // ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours) 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) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу». // ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours) RuleFor(x => x.SlaHours)
@@ -51,14 +51,14 @@ internal static class TemplateRouteStepFields
(projectRole is null or ProjectRole.None ? 0 : 1) + (projectRole is null or ProjectRole.None ? 0 : 1) +
(headType is null or HeadType.None ? 0 : 1); (headType is null or HeadType.None ? 0 : 1);
public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) => /// <summary>
(string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) + /// Условие с пустым field_name/value или operation=None — половинчатое, а не
(operation is null or ComparisonOperator.None ? 0 : 1) + /// «выключенное» (выключается вовсе не добавляя условие в список), поэтому
(string.IsNullOrWhiteSpace(value) ? 0 : 1); /// такое отдельное условие недопустимо, даже если остальные условия шага корректны.
/// </summary>
public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value) public static bool AreConditionsValid(IReadOnlyList<RouteStepConditionDto>? conditions) =>
{ conditions is null || conditions.All(c =>
var count = CountConditionFilled(fieldName, operation, value); !string.IsNullOrWhiteSpace(c.FieldName) &&
return count == 0 || count == 3; c.Operation != ComparisonOperator.None &&
} !string.IsNullOrWhiteSpace(c.Value));
} }
@@ -189,7 +189,7 @@ public sealed class TemplateRouteService(
var matchingSteps = route.Steps var matchingSteps = route.Steps
.Where(s => s.Status == request.Status) .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) .OrderBy(s => s.OrderNo)
.ToList(); .ToList();
@@ -220,7 +220,7 @@ public sealed class TemplateRouteService(
private async Task<TemplateRoute?> FindRouteAsync( private async Task<TemplateRoute?> FindRouteAsync(
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) => int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
await db.TemplateRoutes.AsNoTracking() await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps) .Include(x => x.Steps).ThenInclude(s => s.Conditions)
.Where(x => .Where(x =>
x.RequestTypeId == requestTypeId && x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId && x.CompanyId == companyId &&
@@ -228,7 +228,7 @@ public sealed class TemplateRouteService(
x.DepartmentId == departmentId) x.DepartmentId == departmentId)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking() ?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps) .Include(x => x.Steps).ThenInclude(s => s.Conditions)
.Where(x => .Where(x =>
x.RequestTypeId == requestTypeId && x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId && x.CompanyId == companyId &&
@@ -344,21 +344,29 @@ public sealed class TemplateRouteService(
} }
/// <summary> /// <summary>
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе. /// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля /// применяется всегда. Несколько условий объединяются через <see cref="TemplateRouteStep.ConditionsLogic"/>:
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный /// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано
/// сценарий, и отсутствие значения означает, что условие не выполняется. /// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы
/// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется.
/// </summary> /// </summary>
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues) private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
{ {
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None) if (step.Conditions.Count == 0)
return true; 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<string, string> fieldValues)
{
if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
return false; return false;
var expected = step.Value; var expected = condition.Value;
return step.Operation switch return condition.Operation switch
{ {
ComparisonOperator.Equal => IsEqual(actual, expected), ComparisonOperator.Equal => IsEqual(actual, expected),
ComparisonOperator.NotEqual => !IsEqual(actual, expected), ComparisonOperator.NotEqual => !IsEqual(actual, expected),
@@ -399,14 +407,14 @@ public sealed class TemplateRouteService(
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться."; "поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
/// <summary> /// <summary>
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность /// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с /// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с
/// предупреждением — см. ResolveAsync. /// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага:
/// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен.
/// </summary> /// </summary>
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) => private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
positionMissing && positionMissing &&
string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) && step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase));
step.Operation is not (null or ComparisonOperator.None);
/// <summary> /// <summary>
/// Должность — свободный текст без структуры (нет отдельного флага «это /// Должность — свободный текст без структуры (нет отдельного флага «это
@@ -31,9 +31,11 @@ public class TemplateRouteStep
/// </summary> /// </summary>
public bool? AskOverdueReason { get; set; } public bool? AskOverdueReason { get; set; }
public string? FieldName { get; set; } /// <summary>Как объединяются <see cref="Conditions"/> между собой — не действует, если условий меньше двух.</summary>
public ComparisonOperator? Operation { get; set; } public ConditionsLogic ConditionsLogic { get; set; } = ConditionsLogic.And;
public string? Value { get; set; }
/// <summary>Условия появления шага. Пусто — шаг применяется всегда.</summary>
public List<TemplateRouteStepCondition> Conditions { get; set; } = [];
public TemplateRoute? Route { get; set; } public TemplateRoute? Route { get; set; }
} }
@@ -0,0 +1,24 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Domain.Entities;
/// <summary>
/// Одно условие появления шага (см. <see cref="TemplateRouteStep.Conditions"/>) —
/// сравнение значения поля заявки (<see cref="FieldName"/>) с <see cref="Value"/>
/// через <see cref="Operation"/>. Несколько условий одного шага объединяются
/// через <see cref="TemplateRouteStep.ConditionsLogic"/> (И/ИЛИ).
/// </summary>
public class TemplateRouteStepCondition
{
public int Id { get; set; }
public int StepId { get; set; }
public TemplateRouteStep? Step { get; set; }
/// <summary>Порядок отображения в форме — на вычисление условия не влияет.</summary>
public int OrderNo { get; set; }
public string FieldName { get; set; } = string.Empty;
public ComparisonOperator Operation { get; set; }
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,14 @@
namespace Tnsu.ApprovalRoute.Domain.Enums;
/// <summary>
/// Как объединяются условия шага (<see cref="Entities.TemplateRouteStep.Conditions"/>)
/// между собой, если их несколько. Не действует, если условие одно или их нет вовсе.
/// </summary>
public enum ConditionsLogic
{
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
And,
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
Or
}
@@ -10,6 +10,7 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>(); public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>(); public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>(); public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
public DbSet<TemplateRouteStepCondition> TemplateRouteStepConditions => Set<TemplateRouteStepCondition>();
public DbSet<AppSetting> Settings => Set<AppSetting>(); public DbSet<AppSetting> Settings => Set<AppSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -61,11 +62,19 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32); e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.FieldName).HasMaxLength(128); e.Property(x => x.ConditionsLogic).HasConversion<string>().HasMaxLength(16).IsRequired();
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Value).HasMaxLength(256);
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
e.HasIndex(x => new { x.RouteId, x.OrderNo }); e.HasIndex(x => new { x.RouteId, x.OrderNo });
}); });
modelBuilder.Entity<TemplateRouteStepCondition>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.FieldName).HasMaxLength(128).IsRequired();
e.Property(x => x.Operation).HasConversion<string>().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 });
});
} }
} }
@@ -0,0 +1,366 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("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<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("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
}
}
}
@@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMultipleStepConditions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
step_id = table.Column<int>(type: "integer", nullable: false),
order_no = table.Column<int>(type: "integer", nullable: false),
field_name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
operation = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
value = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "field_name",
table: "template_route_steps",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "operation",
table: "template_route_steps",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
}
}
@@ -175,26 +175,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("ask_overdue_reason"); .HasColumnName("ask_overdue_reason");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail") b.Property<string>("ExecutorEmail")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("FieldName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("HeadType") b.Property<string>("HeadType")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("head_type"); .HasColumnName("head_type");
b.Property<string>("Operation")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo") b.Property<int>("OrderNo")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("order_no"); .HasColumnName("order_no");
@@ -223,11 +219,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("status"); .HasColumnName("status");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_route_steps"); .HasName("pk_template_route_steps");
@@ -237,6 +228,50 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("template_route_steps", (string)null); b.ToTable("template_route_steps", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("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 => modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{ {
b.Property<string>("OptionName") b.Property<string>("OptionName")
@@ -294,6 +329,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.Navigation("Route"); 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 => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{ {
b.Navigation("ListPartitions"); b.Navigation("ListPartitions");
@@ -305,6 +352,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
b.Navigation("Steps"); b.Navigation("Steps");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -204,8 +204,8 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest( new CreateTemplateRouteStepRequest(
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null, 1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null,
stepSla, stepAsk), SlaHours: stepSla, AskOverdueReason: stepAsk),
CancellationToken.None); CancellationToken.None);
var resolved = await _service.ResolveAsync( var resolved = await _service.ResolveAsync(
@@ -283,15 +283,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -320,7 +320,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action — departmentId в запросе не передан // Action — departmentId в запросе не передан
@@ -362,7 +362,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -402,7 +402,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -431,7 +431,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -458,11 +458,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -489,7 +489,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action — fieldValues не переданы вовсе // Action — fieldValues не переданы вовсе
@@ -514,7 +514,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -548,7 +548,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -562,6 +562,47 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0); resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0);
} }
[Theory(DisplayName = "Резолв маршрута с несколькими условиями шага, объединёнными через И/ИЛИ")]
// И: оба условия должны выполниться.
[InlineData(ConditionsLogic.And, "1500", "Urgent", true)]
[InlineData(ConditionsLogic.And, "500", "Urgent", false)]
[InlineData(ConditionsLogic.And, "1500", "Normal", false)]
// ИЛИ: достаточно одного.
[InlineData(ConditionsLogic.Or, "500", "Urgent", true)]
[InlineData(ConditionsLogic.Or, "1500", "Normal", true)]
[InlineData(ConditionsLogic.Or, "500", "Normal", false)]
public async Task ResolveAsync_MultipleConditions_CombinesWithConfiguredLogic(
ConditionsLogic logic, string amount, string priority, bool shouldMatch)
{
// Arrange
var requestTypeId = await CreateRequestTypeWithListAsync($"Multi-condition request {logic} {amount} {priority}");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
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 = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{ {
@@ -574,7 +615,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -612,7 +653,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -651,7 +692,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -686,7 +727,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -713,7 +754,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -742,7 +783,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -771,7 +812,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -59,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -71,20 +71,79 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null); var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None); var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var duplicate = await _service.CreateAsync( var duplicate = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, null, null), new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
duplicate.IsFailed.ShouldBeTrue(); duplicate.IsFailed.ShouldBeTrue();
} }
[Fact(DisplayName = "Порядок условий и логика их объединения не влияют на определение дубликата")]
public async Task CreateAsync_DuplicateStepWithSameConditionsInDifferentOrder_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with duplicate conditions", byProject: false, listName: "Requests");
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false,
new[]
{
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"),
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent")
},
ConditionsLogic.And),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action — те же два условия, в обратном порядке, но с той же логикой И
var duplicate = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false,
new[]
{
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent"),
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000")
},
ConditionsLogic.And),
CancellationToken.None);
// Assert
duplicate.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Те же условия, но с другой логикой объединения (И/ИЛИ), — не дубликат")]
public async Task CreateAsync_SameConditionsWithDifferentLogic_DoesNotConflict()
{
// Arrange
var routeId = await CreateRouteAsync("Route with different conditions logic", byProject: false, listName: "Requests");
var conditions = new[]
{
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"),
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent")
};
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, conditions, ConditionsLogic.And),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action — те же условия, но объединены через ИЛИ, а не И
var second = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, conditions, ConditionsLogic.Or),
CancellationToken.None);
// Assert
second.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")] [Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")]
public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult() public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult()
{ {
@@ -94,14 +153,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief // Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -115,14 +174,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, null, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -138,11 +197,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var chief = await _service.CreateAsync( var chief = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
var directorTansu = await _service.CreateAsync( var directorTansu = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -160,11 +219,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.LessThan, "1000"), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -181,11 +240,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.LessThan, "1000"), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -200,14 +259,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — то же условие, другой статус // Action — то же условие, другой статус
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -221,14 +280,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false); var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId) // Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null), new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -244,7 +303,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
// Assert // Assert
@@ -262,7 +321,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -282,7 +341,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -300,7 +359,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -315,14 +374,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -339,14 +398,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step", byProject: false); var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -363,14 +422,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -384,14 +443,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false); var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -405,14 +464,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -420,16 +479,17 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.Value.ExecutorEmail.ShouldBe("a@b.kz"); patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
} }
[Fact(DisplayName = "Создание шага с частично заполненными полями условия возвращает неуспешный результат")] [Fact(DisplayName = "Создание шага с условием без значения возвращает неуспешный результат")]
public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult() public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests");
// Action // Action — оператор задан, но value пуст: условие наполовину заполнено
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -445,7 +505,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -461,14 +521,15 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
created.Value.FieldName.ShouldBe("Amount"); created.Value.Conditions.ShouldHaveSingleItem();
created.Value.Operation.ShouldBe(ComparisonOperator.GreaterThan); created.Value.Conditions[0].FieldName.ShouldBe("Amount");
created.Value.Value.ShouldBe("1000"); created.Value.Conditions[0].Operation.ShouldBe(ComparisonOperator.GreaterThan);
created.Value.Conditions[0].Value.ShouldBe("1000");
} }
[Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")] [Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")]
@@ -478,14 +539,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: "Amount", Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, Conditions: new[] { new RouteStepConditionDto("Amount", ComparisonOperator.None, "") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -499,7 +560,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -520,16 +581,16 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false); var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
await _service.CreateAsync( await _service.CreateAsync(
routeBId, routeBId,
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, null, null), new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null),
CancellationToken.None); CancellationToken.None);
// Другой исполнитель — не должен попасть в выдачу. // Другой исполнитель — не должен попасть в выдачу.
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -44,9 +44,8 @@ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
headType: step.headType, headType: step.headType,
status: step.status, status: step.status,
allowReassignToOtherApprovers: step.allowReassignToOtherApprovers, allowReassignToOtherApprovers: step.allowReassignToOtherApprovers,
fieldName: step.fieldName, conditions: step.conditions,
operation: step.operation, conditionsLogic: step.conditionsLogic,
value: step.value,
slaHours: step.slaHours, slaHours: step.slaHours,
askOverdueReason: step.askOverdueReason askOverdueReason: step.askOverdueReason
}; };
@@ -1,5 +1,27 @@
@import '../../../../styles/design-tokens'; @import '../../../../styles/design-tokens';
// Список условий появления шага — своя мини-форма после основной сетки полей
// (тот же приём, что и в ResolveScreen для списка проверяемых значений).
.conditions {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid $gray-200;
}
.conditionRow {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.conditionsHint {
margin: -4px 0 10px;
font-size: 11.5px;
color: $gray-400;
}
// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри // Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри
// Field, поэтому отступ сверху небольшой — визуально это продолжение поля, // Field, поэтому отступ сверху небольшой — визуально это продолжение поля,
// а не отдельный блок. // а не отдельный блок.
@@ -2,23 +2,32 @@ import * as React from 'react';
import styles from './RouteStepFormModal.module.scss'; import styles from './RouteStepFormModal.module.scss';
import { import {
ComparisonOperator, ComparisonOperator,
ConditionsLogic,
Employee, Employee,
HeadType, HeadType,
ProjectRole, ProjectRole,
RequestType, RequestType,
RouteStepCondition,
RouteStepStatus, RouteStepStatus,
StepExecutorKind, StepExecutorKind,
TemplateRoute, TemplateRoute,
TemplateRouteStep TemplateRouteStep
} from '../../models'; } from '../../models';
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import {
comparisonOperatorLabels,
conditionsLogicLabels,
headTypeLabels,
projectRoleLabels,
routeStepStatusLabels,
texts
} from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee'; import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; import { Button, Checkbox, Field, FormGrid, FormModal, IconButton, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
import { import {
boolToTriState, boolToTriState,
isValidSlaHours, isValidSlaHours,
@@ -63,14 +72,15 @@ interface IFormState {
headType: HeadType; headType: HeadType;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
hasCondition: boolean; hasCondition: boolean;
fieldName: string; conditions: RouteStepCondition[];
operation: ComparisonOperator; conditionsLogic: ConditionsLogic;
value: string;
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */ /** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
slaHours: string; slaHours: string;
askOverdueReason: TriState; askOverdueReason: TriState;
} }
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] })); .map(v => ({ value: v, label: routeStepStatusLabels[v] }));
@@ -86,6 +96,8 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None) .filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] })); .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]; const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan];
@@ -118,9 +130,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
headType: HeadType.Chief, headType: HeadType.Chief,
allowReassignToOtherApprovers: false, allowReassignToOtherApprovers: false,
hasCondition: false, hasCondition: false,
fieldName: '', conditions: [EMPTY_CONDITION],
operation: ComparisonOperator.GreaterThan, conditionsLogic: ConditionsLogic.And,
value: '',
slaHours: '', slaHours: '',
askOverdueReason: '' askOverdueReason: ''
}; };
@@ -134,10 +145,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers, allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName), hasCondition: editing.conditions.length > 0,
fieldName: editing.fieldName ?? '', conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan, conditionsLogic: editing.conditionsLogic,
value: editing.value ?? '',
slaHours: slaHoursToInput(editing.slaHours), slaHours: slaHoursToInput(editing.slaHours),
askOverdueReason: boolToTriState(editing.askOverdueReason) askOverdueReason: boolToTriState(editing.askOverdueReason)
}; };
@@ -162,59 +172,61 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
const hasListId = Boolean(requestType?.listId); const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); 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 listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions]; const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
if (form.fieldName && !options.some(o => o.value === form.fieldName)) { if (currentValue && !options.some(o => o.value === currentValue)) {
options.push({ value: form.fieldName, label: form.fieldName }); options.push({ value: currentValue, label: currentValue });
} }
return options; return options;
}, [sharePointFields.items, form.fieldName]); };
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
// Тип столбца известен, только если он есть среди подгруженных полей списка — // Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления), // для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные. // ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
// IsManager — служебное булево поле, а не колонка списка, поэтому для него // IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае. // числовые ограничения не действуют в любом случае.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName); const fieldKindOf = (fieldName: string): 'bool' | 'numeric' | 'text' | 'unknown' => {
const fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField if (fieldName === IS_MANAGER_FIELD) return 'bool';
? 'bool' const field = sharePointFields.items.find(f => f.internalName === fieldName);
: !selectedField if (!field) return 'unknown';
? 'unknown' return isNumericFieldType(field.typeAsString) ? 'numeric' : 'text';
: isNumericFieldType(selectedField.typeAsString) };
? 'numeric' const isNumericFieldKind = (kind: ReturnType<typeof fieldKindOf>): boolean => kind === 'numeric' || kind === 'unknown';
: 'text';
const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown';
// Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше». // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше».
// Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем. // Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем.
const operationOptions = React.useMemo(() => OPERATION_OPTIONS.filter(o => { const operationOptionsFor = (kind: ReturnType<typeof fieldKindOf>): typeof OPERATION_OPTIONS =>
if (fieldKind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual; OPERATION_OPTIONS.filter(o => {
if (fieldKind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0; if (kind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual;
if (fieldKind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0; if (kind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0;
if (kind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0;
return true; return true;
}), [fieldKind]); });
// Оператор, недопустимый для выбранной колонки (например, «больше» после смены const updateCondition = (index: number, patch: Partial<RouteStepCondition>): void => {
setForm(f => ({
...f,
conditions: f.conditions.map((c, i) => {
if (i !== index) return c;
const next = { ...c, ...patch };
// Оператор, недопустимый для новой колонки (например, «больше» после смены
// field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда. // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
const isOperationAllowed = operationOptions.some(o => o.value === form.operation); const kind = fieldKindOf(next.fieldName);
React.useEffect(() => { if (!operationOptionsFor(kind).some(o => o.value === next.operation)) next.operation = ComparisonOperator.Equal;
if (!isOperationAllowed) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOperationAllowed]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name. // а не произвольным текстом, который пользователь мог набрать для другого field_name.
React.useEffect(() => { if (kind === 'bool' && next.value !== 'true' && next.value !== 'false') next.value = 'true';
if (isManagerField && form.value !== 'true' && form.value !== 'false') { return next;
setForm(f => ({ ...f, value: 'true' })); })
} }));
// eslint-disable-next-line react-hooks/exhaustive-deps };
}, [isManagerField]); 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), и опечатка тихо // совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо
@@ -336,14 +348,19 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition); submit.fail(texts.routeSteps.validationPosition);
return; return;
} }
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) { if (form.hasCondition) {
for (const c of form.conditions) {
if (!c.fieldName.trim() || !c.value.trim()) {
submit.fail(texts.routeSteps.validationCondition); submit.fail(texts.routeSteps.validationCondition);
return; return;
} }
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) { const kind = fieldKindOf(c.fieldName);
submit.fail(texts.routeSteps.validationConditionNumber); if (isNumericFieldKind(kind) && !Number.isFinite(Number(c.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber(c.fieldName));
return; return;
} }
}
}
if (!isValidSlaHours(form.slaHours)) { if (!isValidSlaHours(form.slaHours)) {
submit.fail(texts.routeSteps.validationSlaHours); submit.fail(texts.routeSteps.validationSlaHours);
return; return;
@@ -357,9 +374,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
position: form.kind === 'position' ? form.position.trim() : null, position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null, headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null, conditions: form.hasCondition
operation: form.hasCondition ? form.operation : null, ? form.conditions.map(c => ({ fieldName: c.fieldName.trim(), operation: c.operation, value: c.value.trim() }))
value: form.hasCondition ? form.value.trim() : null, : null,
conditionsLogic: form.conditionsLogic,
slaHours: parseSlaHours(form.slaHours), slaHours: parseSlaHours(form.slaHours),
askOverdueReason: triStateToBool(form.askOverdueReason) askOverdueReason: triStateToBool(form.askOverdueReason)
}; };
@@ -495,17 +513,29 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/> />
</Field> </Field>
</FormGrid>
{form.hasCondition && ( {form.hasCondition && (
<> <div className={styles.conditions}>
<Field {form.conditions.length > 1 && (
label={texts.routeSteps.conditionFieldNameLabel} <Field label={texts.routeSteps.conditionLogicLabel}>
required <Select
full options={CONDITIONS_LOGIC_OPTIONS}
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined} value={form.conditionsLogic}
> onChange={e => setForm({ ...form, conditionsLogic: e.target.value as ConditionsLogic })}
/>
</Field>
)}
{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 -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
<div className={styles.conditionRow} key={index}>
<Select <Select
options={fieldNameOptions} options={fieldNameOptions}
value={form.fieldName} value={condition.fieldName}
disabled={sharePointFields.isLoading} disabled={sharePointFields.isLoading}
placeholder={ placeholder={
sharePointFields.isLoading sharePointFields.isLoading
@@ -514,35 +544,44 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
? texts.routeSteps.conditionFieldNameEmpty ? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder : texts.routeSteps.conditionFieldNamePlaceholder
} }
onChange={e => setForm({ ...form, fieldName: e.target.value })} onChange={e => updateCondition(index, { fieldName: e.target.value })}
/> />
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select <Select
options={operationOptions} options={operationOptionsFor(kind)}
value={form.operation} value={condition.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })} onChange={e => updateCondition(index, { operation: e.target.value as ComparisonOperator })}
/> />
</Field> {kind === 'bool' ? (
<Field label={texts.routeSteps.conditionValueLabel} required>
{isManagerField ? (
<Select <Select
options={IS_MANAGER_VALUE_OPTIONS} options={IS_MANAGER_VALUE_OPTIONS}
value={form.value} value={condition.value}
onChange={e => setForm({ ...form, value: e.target.value })} onChange={e => updateCondition(index, { value: e.target.value })}
/> />
) : ( ) : (
<TextInput <TextInput
type={isNumericField ? 'number' : 'text'} type={isNumericFieldKind(kind) ? 'number' : 'text'}
value={form.value} value={condition.value}
onChange={e => setForm({ ...form, value: e.target.value })} onChange={e => updateCondition(index, { value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder} placeholder={texts.routeSteps.conditionValuePlaceholder}
/> />
)} )}
</Field> <IconButton
</> icon="trash"
label={texts.routeSteps.conditionRemove}
danger
disabled={form.conditions.length === 1}
onClick={() => removeCondition(index)}
/>
</div>
);
})}
{sharePointFields.error && <div className={styles.conditionsHint}>{texts.routeSteps.conditionFieldNameError(sharePointFields.error)}</div>}
<Button variant="secondary" size="sm" icon="plus" onClick={addCondition}>
{texts.routeSteps.conditionAdd}
</Button>
</div>
)} )}
</FormGrid>
</FormModal> </FormModal>
); );
}; };
@@ -40,3 +40,9 @@ export enum ComparisonOperator {
Contains = 'Contains', Contains = 'Contains',
NotContains = 'NotContains' NotContains = 'NotContains'
} }
/** Как объединяются условия шага между собой — не действует, если условие одно или их нет. */
export enum ConditionsLogic {
And = 'And',
Or = 'Or'
}
@@ -2,7 +2,16 @@
// (Application/TemplateRouteSteps/*.cs). // (Application/TemplateRouteSteps/*.cs).
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */ /* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда (C# nullable-типы сериализуются в null, не в отсутствие поля) */
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums'; import { ComparisonOperator, ConditionsLogic, HeadType, ProjectRole, RouteStepStatus } from './enums';
/** Одно условие появления шага — см. Application/TemplateRouteSteps/RouteStepConditionDto. */
export interface RouteStepCondition {
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
fieldName: string;
operation: ComparisonOperator;
/** Значение для сравнения с fieldName. */
value: string;
}
export interface TemplateRouteStep { export interface TemplateRouteStep {
id: number; id: number;
@@ -14,11 +23,10 @@ export interface TemplateRouteStep {
status: RouteStepStatus; status: RouteStepStatus;
routeId: number; routeId: number;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */ /** Условия появления шага. Пусто — шаг применяется всегда. */
fieldName: string | null; conditions: RouteStepCondition[];
operation: ComparisonOperator | null; /** Как объединяются conditions между собой — не действует, если условий меньше двух. */
/** Значение для сравнения с fieldName. */ conditionsLogic: ConditionsLogic;
value: string | null;
/** /**
* SLA шага в часах — перекрывает SLA типа заявки. * SLA шага в часах — перекрывает SLA типа заявки.
* null — «не задано, наследовать»; 0 — «напоминать сразу». * null — «не задано, наследовать»; 0 — «напоминать сразу».
@@ -38,9 +46,8 @@ export interface CreateTemplateRouteStepInput {
headType: HeadType | null; headType: HeadType | null;
status: RouteStepStatus; status: RouteStepStatus;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
fieldName: string | null; conditions: RouteStepCondition[] | null;
operation: ComparisonOperator | null; conditionsLogic: ConditionsLogic;
value: string | null;
slaHours: number | null; slaHours: number | null;
askOverdueReason: boolean | null; askOverdueReason: boolean | null;
} }
@@ -55,9 +62,8 @@ export interface PatchTemplateRouteStepInput {
headType?: HeadType | null; headType?: HeadType | null;
status?: RouteStepStatus; status?: RouteStepStatus;
allowReassignToOtherApprovers?: boolean; allowReassignToOtherApprovers?: boolean;
fieldName?: string | null; conditions?: RouteStepCondition[] | null;
operation?: ComparisonOperator | null; conditionsLogic?: ConditionsLogic;
value?: string | null;
slaHours?: number | null; slaHours?: number | null;
askOverdueReason?: boolean | null; askOverdueReason?: boolean | null;
} }
+18 -3
View File
@@ -4,7 +4,7 @@
// //
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались. // Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './models'; import { ComparisonOperator, ConditionsLogic, HeadType, ProjectRole, RouteStepStatus } from './models';
import { pluralRu } from './utils/plural'; import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ---- // ---- Подписи enum'ов ----
@@ -55,6 +55,17 @@ export const comparisonOperatorSymbols: Record<ComparisonOperator, string> = {
[ComparisonOperator.NotContains]: 'не содержит' [ComparisonOperator.NotContains]: 'не содержит'
}; };
export const conditionsLogicLabels: Record<ConditionsLogic, string> = {
[ConditionsLogic.And]: 'И (выполняются все условия)',
[ConditionsLogic.Or]: 'ИЛИ (достаточно одного условия)'
};
/** Как условия шага склеиваются в одну строку в колонке «Условие» таблицы шагов. */
export const conditionsLogicJoiners: Record<ConditionsLogic, string> = {
[ConditionsLogic.And]: ' И ',
[ConditionsLogic.Or]: ' ИЛИ '
};
export const texts = { export const texts = {
common: { common: {
home: 'Главная', home: 'Главная',
@@ -330,11 +341,15 @@ export const texts = {
conditionOperationLabel: 'Оператор сравнения', conditionOperationLabel: 'Оператор сравнения',
conditionValueLabel: 'Значение', conditionValueLabel: 'Значение',
conditionValuePlaceholder: 'Например, 1000', conditionValuePlaceholder: 'Например, 1000',
conditionLogicLabel: 'Логика объединения условий',
conditionAdd: 'Добавить условие',
conditionRemove: 'Удалить условие',
validationOrderNo: 'Порядковый номер шага обязателен.', validationOrderNo: 'Порядковый номер шага обязателен.',
validationExecutor: 'Выберите сотрудника-исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.', validationCondition: 'В каждом условии заполните поле списка, оператор и значение.',
validationConditionNumber: 'Выбранное поле числовое — значение условия должно быть числом.', validationConditionNumber: (fieldName: string): string =>
`Поле «${fieldName}» числовое — значение условия должно быть числом.`,
validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.', validationSlaHours: 'SLA укажите целым числом часов (0 и больше) или оставьте поле пустым.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
@@ -1,4 +1,4 @@
import { ComparisonOperator, Employee, HeadType, ProjectRole, RouteStepStatus, TemplateRouteStep } from '../models'; import { ComparisonOperator, ConditionsLogic, Employee, HeadType, ProjectRole, RouteStepStatus, TemplateRouteStep } from '../models';
import { describeStepCondition, describeStepExecutor, detectExecutorKind } from './stepExecutor'; import { describeStepCondition, describeStepExecutor, detectExecutorKind } from './stepExecutor';
function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep { function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
@@ -12,9 +12,8 @@ function step(overrides: Partial<TemplateRouteStep>): TemplateRouteStep {
status: RouteStepStatus.Approval, status: RouteStepStatus.Approval,
routeId: 1, routeId: 1,
allowReassignToOtherApprovers: false, allowReassignToOtherApprovers: false,
fieldName: null, conditions: [],
operation: null, conditionsLogic: ConditionsLogic.And,
value: null,
slaHours: null, slaHours: null,
askOverdueReason: null, askOverdueReason: null,
...overrides ...overrides
@@ -95,16 +94,40 @@ describe('describeStepExecutor', () => {
}); });
describe('describeStepCondition', () => { describe('describeStepCondition', () => {
it('undefined, если условие не задано целиком', () => { it('undefined, если условий нет', () => {
expect(describeStepCondition(step({}))).toBeUndefined(); expect(describeStepCondition(step({}))).toBeUndefined();
}); });
it('undefined, если operation === None', () => { it('собирает "поле оператор значение", если условие одно', () => {
expect(describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.None, value: '100' }))).toBeUndefined(); const result = describeStepCondition(
}); step({ conditions: [{ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '100' }] })
);
it('собирает "поле оператор значение", если условие задано полностью', () => {
const result = describeStepCondition(step({ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '100' }));
expect(result).toBe('Amount > 100'); expect(result).toBe('Amount > 100');
}); });
it('несколько условий склеиваются через "И"', () => {
const result = describeStepCondition(
step({
conditions: [
{ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '1000' },
{ fieldName: 'Priority', operation: ComparisonOperator.Equal, value: 'Urgent' }
],
conditionsLogic: ConditionsLogic.And
})
);
expect(result).toBe('Amount > 1000 И Priority = Urgent');
});
it('несколько условий склеиваются через "ИЛИ"', () => {
const result = describeStepCondition(
step({
conditions: [
{ fieldName: 'Amount', operation: ComparisonOperator.GreaterThan, value: '1000' },
{ fieldName: 'Priority', operation: ComparisonOperator.Equal, value: 'Urgent' }
],
conditionsLogic: ConditionsLogic.Or
})
);
expect(result).toBe('Amount > 1000 ИЛИ Priority = Urgent');
});
}); });
@@ -1,5 +1,5 @@
import { ComparisonOperator, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models'; import { ConditionsLogic, Employee, HeadType, ProjectRole, StepExecutorKind, TemplateRouteStep } from '../models';
import { comparisonOperatorSymbols, headTypeLabels, projectRoleLabels, texts } from '../texts'; import { comparisonOperatorSymbols, conditionsLogicJoiners, headTypeLabels, projectRoleLabels, texts } from '../texts';
import { findEmployeeByEmail } from './employee'; import { findEmployeeByEmail } from './employee';
/** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */ /** Каким из четырёх взаимоисключающих способов у шага задан исполнитель. */
@@ -42,8 +42,11 @@ export function describeStepExecutor(step: TemplateRouteStep, employees: Employe
return { primary: texts.common.empty }; return { primary: texts.common.empty };
} }
/** Колонка «Условие» в таблице шагов — undefined, если у шага условия нет. */ /** Колонка «Условие» в таблице шагов — undefined, если у шага условий нет. */
export function describeStepCondition(step: TemplateRouteStep): string | undefined { export function describeStepCondition(step: TemplateRouteStep): string | undefined {
if (!step.fieldName || !step.operation || step.operation === ComparisonOperator.None || !step.value) return undefined; if (step.conditions.length === 0) return undefined;
return `${step.fieldName} ${comparisonOperatorSymbols[step.operation]} ${step.value}`; const joiner = conditionsLogicJoiners[step.conditionsLogic ?? ConditionsLogic.And];
return step.conditions
.map(c => `${c.fieldName} ${comparisonOperatorSymbols[c.operation]} ${c.value}`)
.join(joiner);
} }