Несколько условий шага, объединяемых через И/ИЛИ
Раньше шаг маршрута мог нести ровно одно условие (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:
@@ -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);
|
||||||
|
|||||||
+107
-66
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+366
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-15
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-22
@@ -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
|
||||||
|
|||||||
+107
-46
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user