Несколько условий шага, объединяемых через И/ИЛИ (#16)
Tests / frontend-test (push) Successful in 47s
Tests / backend-test (push) Successful in 2m12s

<html>
<body>
<!--StartFragment-->
GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors
--

<!--EndFragment-->
</body>
</html>[GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors](http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/commit/572749b4d7cc11bc8e1ef4184a93f18a14b710ab)

---------

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/16
This commit was merged in pull request #16.
This commit is contained in:
2026-08-25 07:04:44 +00:00
parent e4a6cb8167
commit ba48ed1a0e
27 changed files with 1622 additions and 413 deletions
@@ -7,6 +7,9 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец // (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
// записей, чтобы не ломать существующие позиционные вызовы. // записей, чтобы не ломать существующие позиционные вызовы.
/// <summary>Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition.</summary>
public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value);
public sealed record TemplateRouteStepDto( public sealed record TemplateRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
@@ -17,9 +20,8 @@ public sealed record TemplateRouteStepDto(
RouteStepStatus Status, RouteStepStatus Status,
int RouteId, int RouteId,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto> Conditions,
ComparisonOperator? Operation, ConditionsLogic ConditionsLogic,
string? Value,
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -32,9 +34,8 @@ public sealed record CreateTemplateRouteStepRequest(
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto>? Conditions,
ComparisonOperator? Operation, ConditionsLogic ConditionsLogic = ConditionsLogic.And,
string? Value,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -46,12 +47,16 @@ public sealed record UpdateTemplateRouteStepRequest(
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto>? Conditions,
ComparisonOperator? Operation, ConditionsLogic ConditionsLogic = ConditionsLogic.And,
string? Value,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
/// <summary>
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше
/// для FieldName) — очистка условий делается через PUT с Conditions: [].
/// </summary>
public sealed record PatchTemplateRouteStepRequest( public sealed record PatchTemplateRouteStepRequest(
int? OrderNo, int? OrderNo,
string? ExecutorEmail, string? ExecutorEmail,
@@ -60,8 +65,7 @@ public sealed record PatchTemplateRouteStepRequest(
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus? Status, RouteStepStatus? Status,
bool? AllowReassignToOtherApprovers, bool? AllowReassignToOtherApprovers,
string? FieldName, IReadOnlyList<RouteStepConditionDto>? Conditions,
ComparisonOperator? Operation, ConditionsLogic? ConditionsLogic = null,
string? Value,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -15,14 +15,12 @@ public sealed class TemplateRouteStepService(
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{ {
var items = await db.TemplateRouteSteps.AsNoTracking() var items = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.Where(x => x.RouteId == routeId) .Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo) .OrderBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto(
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
} }
/// <summary> /// <summary>
@@ -38,19 +36,18 @@ public sealed class TemplateRouteStepService(
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>()); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
var items = await db.TemplateRouteSteps.AsNoTracking() var items = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail)) .Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo) .OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto(
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
} }
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken) public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRouteSteps.AsNoTracking() var entity = await db.TemplateRouteSteps.AsNoTracking()
.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
return entity is null return entity is null
@@ -68,22 +65,22 @@ public sealed class TemplateRouteStepService(
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); "В каждом условии (field_name, operation, value) все три поля обязательны."));
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken); r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken);
if (duplicate) if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
@@ -97,9 +94,8 @@ public sealed class TemplateRouteStepService(
HeadType = r.HeadType, HeadType = r.HeadType,
Status = r.Status, Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
FieldName = r.FieldName, ConditionsLogic = r.ConditionsLogic,
Operation = r.Operation, Conditions = ToEntities(r.Conditions),
Value = r.Value,
SlaHours = r.SlaHours, SlaHours = r.SlaHours,
AskOverdueReason = r.AskOverdueReason AskOverdueReason = r.AskOverdueReason
}; };
@@ -118,7 +114,8 @@ public sealed class TemplateRouteStepService(
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null) if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
@@ -126,22 +123,22 @@ public sealed class TemplateRouteStepService(
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); "В каждом условии (field_name, operation, value) все три поля обязательны."));
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken); r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
@@ -152,9 +149,8 @@ public sealed class TemplateRouteStepService(
entity.HeadType = r.HeadType; entity.HeadType = r.HeadType;
entity.Status = r.Status; entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
entity.FieldName = r.FieldName; entity.ConditionsLogic = r.ConditionsLogic;
entity.Operation = r.Operation; ReplaceConditions(entity, r.Conditions);
entity.Value = r.Value;
entity.SlaHours = r.SlaHours; entity.SlaHours = r.SlaHours;
entity.AskOverdueReason = r.AskOverdueReason; entity.AskOverdueReason = r.AskOverdueReason;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -171,7 +167,8 @@ public sealed class TemplateRouteStepService(
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null) if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
@@ -180,9 +177,8 @@ public sealed class TemplateRouteStepService(
var projectRole = r.ProjectRole ?? entity.ProjectRole; var projectRole = r.ProjectRole ?? entity.ProjectRole;
var headType = r.HeadType ?? entity.HeadType; var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status; var status = r.Status ?? entity.Status;
var fieldName = r.FieldName ?? entity.FieldName; var conditions = r.Conditions ?? ToDtos(entity.Conditions);
var operation = r.Operation ?? entity.Operation; var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic;
var value = r.Value ?? entity.Value;
if (status == RouteStepStatus.None) if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution.")); return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
@@ -195,22 +191,22 @@ public sealed class TemplateRouteStepService(
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value)) if (!TemplateRouteStepFields.AreConditionsValid(conditions))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); "В каждом условии (field_name, operation, value) все три поля обязательны."));
if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId)) if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id).")); "Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status, route.Id, executorEmail, position, projectRole, headType, status,
fieldName, operation, value, excludeId: entity.Id, cancellationToken); conditions, conditionsLogic, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте.")); "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken)) if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError( return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим.")); $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
@@ -221,9 +217,9 @@ public sealed class TemplateRouteStepService(
entity.HeadType = headType; entity.HeadType = headType;
entity.Status = status; entity.Status = status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
entity.FieldName = fieldName; entity.ConditionsLogic = conditionsLogic;
entity.Operation = operation; if (r.Conditions is not null)
entity.Value = value; ReplaceConditions(entity, r.Conditions);
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к // Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
// наследованию SLA от типа заявки можно через PUT. // наследованию SLA от типа заявки можно через PUT.
entity.SlaHours = r.SlaHours ?? entity.SlaHours; entity.SlaHours = r.SlaHours ?? entity.SlaHours;
@@ -249,61 +245,108 @@ 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/CompanyDirectors)
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для /// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
/// DepartmentHead, единственный departmentId запроса (см. /// инициатора, единственный departmentId запроса (для DepartmentHead, см.
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора. /// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы /// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
/// в одного и того же (или неотличимо какого) руководителя — например, «два /// один конкретный человек; для CompanyDirectors — один и тот же набор людей
/// разных директора одной и той же компании» просто не существуют. В отличие /// (директор каждой компании инициатора), но тоже всегда один и тот же для
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь /// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных /// HeadType всегда резолвились бы одинаково — например, «два разных директора
/// полей шага. Исключение — условие появления шага: при разных условиях /// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
/// в конкретный резолв попадёт только один из шагов, так что одного и того же /// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
/// руководителя дважды в маршруте не окажется. /// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
/// </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 +366,5 @@ public sealed class TemplateRouteStepService(
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers, e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason); ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason);
} }
@@ -12,8 +12,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу». // ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours) RuleFor(x => x.SlaHours)
@@ -32,8 +32,8 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации; // Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу». // ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours) RuleFor(x => x.SlaHours)
@@ -51,14 +51,14 @@ internal static class TemplateRouteStepFields
(projectRole is null or ProjectRole.None ? 0 : 1) + (projectRole is null or ProjectRole.None ? 0 : 1) +
(headType is null or HeadType.None ? 0 : 1); (headType is null or HeadType.None ? 0 : 1);
public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) => /// <summary>
(string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) + /// Условие с пустым field_name/value или operation=None — половинчатое, а не
(operation is null or ComparisonOperator.None ? 0 : 1) + /// «выключенное» (выключается вовсе не добавляя условие в список), поэтому
(string.IsNullOrWhiteSpace(value) ? 0 : 1); /// такое отдельное условие недопустимо, даже если остальные условия шага корректны.
/// </summary>
public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value) public static bool AreConditionsValid(IReadOnlyList<RouteStepConditionDto>? conditions) =>
{ conditions is null || conditions.All(c =>
var count = CountConditionFilled(fieldName, operation, value); !string.IsNullOrWhiteSpace(c.FieldName) &&
return count == 0 || count == 3; c.Operation != ComparisonOperator.None &&
} !string.IsNullOrWhiteSpace(c.Value));
} }
@@ -13,6 +13,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
/// <param name="AskOverdueReason"> /// <param name="AskOverdueReason">
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием. /// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
/// </param> /// </param>
/// <param name="PositionCode">
/// HeadType шага (Chief/DirectionHead/DirectorTansu/ManagingDirectorTansu/DepartmentHead),
/// если исполнитель задан типом руководителя, иначе null. В отличие от <see cref="Position"/>
/// (реальная должность резолвнутого сотрудника из справочника api.tnsu.kz — свободный
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
/// </param>
public sealed record ResolvedRouteStepDto( public sealed record ResolvedRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
@@ -21,7 +28,8 @@ public sealed record ResolvedRouteStepDto(
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? Warning = null, string? Warning = null,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null,
HeadType? PositionCode = null);
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
int RouteId, int RouteId,
@@ -142,18 +142,30 @@ public sealed class TemplateRouteService(
if (requestType is null) if (requestType is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId)); return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); // /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
if (employee is null) // компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employees.Count == 0)
return Result.Fail<ResolveTemplateRouteResultDto>( return Result.Fail<ResolveTemplateRouteResultDto>(
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email)); new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
var primaryEmployee = employees[0];
var initiatorCompanies = employees
.Select(e => e.Company)
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var byProject = request.ProjectId is not null; var byProject = request.ProjectId is not null;
// Приоритет — маршрут с компанией инициатора; если для этой компании // Приоритет — маршрут с компанией инициатора; если для этой компании
// своего маршрута нет, берём запасной маршрут без указанной компании // своего маршрута нет, берём запасной маршрут без указанной компании
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync). // (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken) var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken); ?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
if (route is null) if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError( return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
@@ -189,7 +201,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();
@@ -200,17 +212,51 @@ public sealed class TemplateRouteService(
// соответствует порядку задач, а не порядку завершения), поэтому // соответствует порядку задач, а не порядку завершения), поэтому
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ // OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
// дубликат считается первым) продолжают работать так же, как при // дубликат считается первым) продолжают работать так же, как при
// последовательном foreach. // последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync( // строк результата (HeadType.CompanyDirectors — по одной на компанию
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, // инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto,
// и результат нужно сначала сгладить (SelectMany).
var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType,
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null, HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
cancellationToken))); cancellationToken)));
var collapsedSteps = CollapseByExecutorEmail(steps); var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList());
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
} }
/// <summary>
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail):
/// тот считает разные PositionCode у одного email конфликтом и пишет
/// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто
/// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним
/// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors
/// оставляет его на своём месте — в конце.
/// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
IReadOnlyList<ResolvedRouteStepDto> steps)
{
var directorTansuEmails = steps
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail))
.Select(s => s.ExecutorEmail!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (directorTansuEmails.Count == 0)
return steps;
return steps
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|| !directorTansuEmails.Contains(s.ExecutorEmail))
.ToList();
}
/// <summary> /// <summary>
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное /// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела /// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
@@ -220,7 +266,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 +274,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 &&
@@ -252,12 +298,104 @@ public sealed class TemplateRouteService(
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты /// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага. /// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param> /// </param>
private async Task<ResolvedRouteStepDto> ResolveStepAsync( /// <summary>
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, /// Название должности директора для конкретной компании — по умолчанию «Директор»,
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
/// поэтому для HeadType.CompanyDirectors ищем по этому названию через
/// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
/// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
/// отдельного источника правды для него в системе нет.
/// </summary>
private static readonly IReadOnlyDictionary<string, string> DirectorPositionByCompany =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["ТОО KazPromService"] = "Главный механик",
["ТОО СТ Global"] = "Генеральный директор"
};
private const string DefaultDirectorPosition = "Директор";
private static string DirectorPositionFor(string company) =>
DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
/// <summary>
/// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
/// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
/// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
/// шаге, то есть параллельны по построению, а не потому что кто-то расставил
/// им одинаковый порядок вручную.
/// </summary>
/// <param name="initiatorCompanies">
/// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
/// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
/// используют одну <paramref name="initiatorCompany"/> (первую по счёту).
/// </param>
/// <param name="conditionWarning">
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param>
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType, IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning, string? conditionWarning,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
{
string? resolvedPosition = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position;
}
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
resolvedPosition,
step.AllowReassignToOtherApprovers,
combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason,
step.HeadType is null or HeadType.None ? null : step.HeadType);
}
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
// и до общего "одна строка на шаг" пути ниже.
if (step.HeadType == HeadType.CompanyDirectors)
{
if (initiatorCompanies.Count == 0)
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
var dtos = new List<ResolvedRouteStepDto>();
foreach (var company in initiatorCompanies)
{
var directorPosition = DirectorPositionFor(company);
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken);
var directorEmail = employeeByPosition.Email;
string? directorWarning = null;
if (!employeeByPosition.Found)
directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}».";
else if (string.IsNullOrWhiteSpace(directorEmail))
directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала.";
dtos.Add(await BuildDtoAsync(directorEmail, directorWarning));
}
return dtos;
}
string? resolvedEmail; string? resolvedEmail;
string? warning = null; string? warning = null;
@@ -318,47 +456,33 @@ public sealed class TemplateRouteService(
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей."; warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
} }
string? resolvedPosition = null; return [await BuildDtoAsync(resolvedEmail, warning)];
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployee?.Position;
}
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
// работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto(
step.Id,
step.OrderNo,
resolvedEmail,
resolvedPosition,
step.AllowReassignToOtherApprovers,
combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason);
} }
/// <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 +523,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>
/// Должность — свободный текст без структуры (нет отдельного флага «это /// Должность — свободный текст без структуры (нет отдельного флага «это
@@ -423,17 +547,50 @@ public sealed class TemplateRouteService(
!string.IsNullOrWhiteSpace(position) && !string.IsNullOrWhiteSpace(position) &&
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase)); ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
/// хуже, чем показать чуть неточный PositionCode с пояснением.
/// </summary>
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps) private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
{ {
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var indexByEmail = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var result = new List<ResolvedRouteStepDto>(); var result = new List<ResolvedRouteStepDto>();
foreach (var step in steps) foreach (var step in steps)
{ {
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail)) if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
continue; {
result.Add(step); result.Add(step);
continue;
}
if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index))
{
indexByEmail[step.ExecutorEmail] = result.Count;
result.Add(step);
continue;
}
var existing = result[index];
if (existing.PositionCode is null && step.PositionCode is not null)
{
result[index] = existing with { PositionCode = step.PositionCode };
}
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
{
var conflictWarning =
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
result[index] = existing with { Warning = combinedWarning };
}
} }
return result; return result;
@@ -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
}
@@ -15,5 +15,15 @@ public enum HeadType
/// подчинённости самого инициатора. В маршруте не может быть больше одного /// подчинённости самого инициатора. В маршруте не может быть больше одного
/// шага с этим типом (см. TemplateRouteStepService). /// шага с этим типом (см. TemplateRouteStepService).
/// </summary> /// </summary>
DepartmentHead DepartmentHead,
/// <summary>
/// Директора ВСЕХ компаний, в которых числится инициатор (сотрудник может
/// работать в нескольких компаниях — /Employee/ByEmail теперь возвращает
/// массив). В отличие от остальных значений этого enum'а, один такой шаг
/// резолвится в НЕСКОЛЬКО строк результата — по одной на каждую компанию
/// инициатора, все с одним и тем же OrderNo (то есть параллельно, см.
/// TemplateRouteService.ResolveStepAsync).
/// </summary>
CompanyDirectors
} }
@@ -10,6 +10,7 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>(); public DbSet<RequestListPartition> RequestListPartitions => Set<RequestListPartition>();
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>(); public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>(); public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
public DbSet<TemplateRouteStepCondition> TemplateRouteStepConditions => Set<TemplateRouteStepCondition>();
public DbSet<AppSetting> Settings => Set<AppSetting>(); public DbSet<AppSetting> Settings => Set<AppSetting>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -61,11 +62,19 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32); e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.FieldName).HasMaxLength(128); e.Property(x => x.ConditionsLogic).HasConversion<string>().HasMaxLength(16).IsRequired();
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Value).HasMaxLength(256);
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
e.HasIndex(x => new { x.RouteId, x.OrderNo }); e.HasIndex(x => new { x.RouteId, x.OrderNo });
}); });
modelBuilder.Entity<TemplateRouteStepCondition>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.FieldName).HasMaxLength(128).IsRequired();
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.Value).HasMaxLength(256).IsRequired();
e.HasOne(x => x.Step).WithMany(x => x.Conditions).HasForeignKey(x => x.StepId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.StepId, x.OrderNo });
});
} }
} }
@@ -0,0 +1,366 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260825044822_AddMultipleStepConditions")]
partial class AddMultipleStepConditions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("ListId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<int>("Number")
.HasColumnType("integer")
.HasColumnName("number");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_request_list_partitions");
b.HasIndex("RequestTypeId", "Number")
.IsUnique()
.HasDatabaseName("ix_request_list_partitions_request_type_id_number");
b.ToTable("request_list_partitions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ListId")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("list_id");
b.Property<DateTimeOffset?>("ListPartitioningStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("list_partitioning_started_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.HasKey("Id")
.HasName("pk_request_types");
b.ToTable("request_types", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<bool?>("AskOverdueReason")
.HasColumnType("boolean")
.HasColumnName("ask_overdue_reason");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<int?>("SlaHours")
.HasColumnType("integer")
.HasColumnName("sla_hours");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("template_route_steps", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{
b.Property<string>("OptionName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("option_name");
b.Property<byte[]>("FileData")
.HasColumnType("bytea")
.HasColumnName("file_data");
b.Property<string>("Value")
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("OptionName")
.HasName("pk_settings");
b.ToTable("settings", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestListPartition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("ListPartitions")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_request_list_partitions_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes")
.HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps")
.HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Navigation("ListPartitions");
b.Navigation("Routes");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Navigation("Steps");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMultipleStepConditions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "conditions_logic",
table: "template_route_steps",
type: "character varying(16)",
maxLength: 16,
nullable: false,
defaultValue: "And");
migrationBuilder.CreateTable(
name: "template_route_step_conditions",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
step_id = table.Column<int>(type: "integer", nullable: false),
order_no = table.Column<int>(type: "integer", nullable: false),
field_name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
operation = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
value = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_template_route_step_conditions", x => x.id);
table.ForeignKey(
name: "fk_template_route_step_conditions_template_route_steps_step_id",
column: x => x.step_id,
principalTable: "template_route_steps",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_template_route_step_conditions_step_id_order_no",
table: "template_route_step_conditions",
columns: new[] { "step_id", "order_no" });
// Существующее одиночное условие шага (field_name/operation/value) переносим
// как первое (и единственное) условие в новую таблицу — до удаления старых
// колонок, иначе уже настроенные в деве/проде условия шагов молча пропадут.
migrationBuilder.Sql(
"""
INSERT INTO template_route_step_conditions (step_id, order_no, field_name, operation, value)
SELECT id, 0, field_name, operation, value
FROM template_route_steps
WHERE field_name IS NOT NULL AND operation IS NOT NULL AND value IS NOT NULL;
""");
migrationBuilder.DropColumn(
name: "field_name",
table: "template_route_steps");
migrationBuilder.DropColumn(
name: "operation",
table: "template_route_steps");
migrationBuilder.DropColumn(
name: "value",
table: "template_route_steps");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "field_name",
table: "template_route_steps",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "operation",
table: "template_route_steps",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "value",
table: "template_route_steps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
// Откат теряет условия сверх первого (по order_no) — старая схема хранила
// только одно условие на шаг, второе и далее девать некуда.
migrationBuilder.Sql(
"""
UPDATE template_route_steps s
SET field_name = c.field_name, operation = c.operation, value = c.value
FROM (
SELECT DISTINCT ON (step_id) step_id, field_name, operation, value
FROM template_route_step_conditions
ORDER BY step_id, order_no
) c
WHERE c.step_id = s.id;
""");
migrationBuilder.DropTable(
name: "template_route_step_conditions");
migrationBuilder.DropColumn(
name: "conditions_logic",
table: "template_route_steps");
}
}
}
@@ -175,26 +175,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("ask_overdue_reason"); .HasColumnName("ask_overdue_reason");
b.Property<string>("ConditionsLogic")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("conditions_logic");
b.Property<string>("ExecutorEmail") b.Property<string>("ExecutorEmail")
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("FieldName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("HeadType") b.Property<string>("HeadType")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("head_type"); .HasColumnName("head_type");
b.Property<string>("Operation")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo") b.Property<int>("OrderNo")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("order_no"); .HasColumnName("order_no");
@@ -223,11 +219,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("status"); .HasColumnName("status");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_route_steps"); .HasName("pk_template_route_steps");
@@ -237,6 +228,50 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("template_route_steps", (string)null); b.ToTable("template_route_steps", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FieldName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<int>("StepId")
.HasColumnType("integer")
.HasColumnName("step_id");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_template_route_step_conditions");
b.HasIndex("StepId", "OrderNo")
.HasDatabaseName("ix_template_route_step_conditions_step_id_order_no");
b.ToTable("template_route_step_conditions", (string)null);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Infrastructure.Persistence.Entities.AppSetting", b =>
{ {
b.Property<string>("OptionName") b.Property<string>("OptionName")
@@ -294,6 +329,18 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.Navigation("Route"); b.Navigation("Route");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStepCondition", b =>
{
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", "Step")
.WithMany("Conditions")
.HasForeignKey("StepId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_template_route_step_conditions_template_route_steps_step_id");
b.Navigation("Step");
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{ {
b.Navigation("ListPartitions"); b.Navigation("ListPartitions");
@@ -305,6 +352,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
b.Navigation("Steps"); b.Navigation("Steps");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Navigation("Conditions");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -17,7 +17,7 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) => public Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) =>
GetOrAddAsync( GetOrAddAsync(
$"emp:email:{email.ToLowerInvariant()}", $"emp:email:{email.ToLowerInvariant()}",
() => inner.GetEmployeeByEmailAsync(email, cancellationToken)); () => inner.GetEmployeeByEmailAsync(email, cancellationToken));
@@ -12,14 +12,16 @@ public sealed class HttpTnsuPortalClient(
{ {
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) public async Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{ {
var employee = await GetAsync<EmployeeResponse>( var employees = await GetAsync<List<EmployeeResponse>>(
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken); $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
if (employee is null) if (employees is null)
return null; return [];
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position); return employees
.Select(e => new EmployeeDirectoryInfo(e.CompanyId ?? Guid.Empty, e.DepartmentId, e.Company ?? string.Empty, e.Position))
.ToList();
} }
public async Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) public async Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
@@ -2,7 +2,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public interface ITnsuPortalClient public interface ITnsuPortalClient
{ {
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); /// <summary>
/// Все записи сотрудника по email — /Employee/ByEmail отдаёт массив: сотрудник может
/// числиться в нескольких компаниях одновременно (один Id/Fio/Email, разные Company/
/// CompanyId/Section/DepartmentId/Position на каждую). Пусто, если не найден вовсе.
/// </summary>
Task<IReadOnlyList<EmployeeDirectoryInfo>> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
@@ -27,7 +27,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
@@ -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
@@ -182,6 +182,8 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.RouteId.ShouldBe(route.Value.Id); resolved.Value.RouteId.ShouldBe(route.Value.Id);
resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
// Исполнитель задан email'ом, а не типом руководителя — PositionCode пуст.
resolved.Value.Steps[0].PositionCode.ShouldBeNull();
} }
// SLA приезжает в резолв уже с применённым наследованием — это осознанное // SLA приезжает в резолв уже с применённым наследованием — это осознанное
@@ -204,8 +206,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(
@@ -265,7 +267,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient portalClient
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>())) .Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
.ReturnsAsync("dup@tnsu.kz"); .ReturnsAsync("dup@tnsu.kz");
@@ -283,15 +285,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
@@ -304,10 +306,65 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps.Count.ShouldBe(2); resolved.Value.Steps.Count.ShouldBe(2);
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz"); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
resolved.Value.Steps[0].OrderNo.ShouldBe(1); resolved.Value.Steps[0].OrderNo.ShouldBe(1);
// Схлопнутый шаг #1 задан по email (PositionCode пуст), но дубликат — шаг #3
// по HeadType.Chief — принёс PositionCode. Он не должен потеряться при схлопывании.
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz"); resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
resolved.Value.Steps[1].OrderNo.ShouldBe(2); resolved.Value.Steps[1].OrderNo.ShouldBe(2);
} }
[Fact(DisplayName = "Резолв маршрута: один email через два разных HeadType — предупреждение, PositionCode первого из них")]
public async Task ResolveAsync_StepsResolveToSameEmailWithDifferentPositionCode_AddsWarningAndKeepsFirst()
{
// Arrange — оба типа руководителя резолвятся в один и тот же email.
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
.ReturnsAsync("same@tnsu.kz");
portalClient
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "directionHead", It.IsAny<CancellationToken>()))
.ReturnsAsync("same@tnsu.kz");
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Collapse conflicting position code request", "/collapse-conflicting-position-code", null, null, "General"),
CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("same@tnsu.kz");
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
var warning = resolved.Value.Steps[0].Warning;
warning.ShouldNotBeNull();
warning.ShouldContain("Chief");
warning.ShouldContain("DirectionHead");
}
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")] [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor() public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor()
{ {
@@ -320,7 +377,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 в запросе не передан
@@ -345,7 +402,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient portalClient
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>())) .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync("head-of-department@tnsu.kz"); .ReturnsAsync("head-of-department@tnsu.kz");
@@ -362,7 +419,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
@@ -385,7 +442,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
portalClient portalClient
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>())) .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync((string?)null); .ReturnsAsync((string?)null);
@@ -402,7 +459,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 +488,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 +515,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 +546,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 +571,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 +605,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 +619,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 +672,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
@@ -595,7 +693,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer") });
portalClient portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович")); .ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
@@ -612,7 +710,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
@@ -634,7 +732,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position") });
portalClient portalClient
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>())) .Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
.ReturnsAsync("chief@tnsu.kz"); .ReturnsAsync("chief@tnsu.kz");
@@ -651,7 +749,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
@@ -663,6 +761,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.IsSuccess.ShouldBeTrue(); resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz"); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz");
resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief);
} }
[Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")] [Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")]
@@ -672,7 +771,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position")); .ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position") });
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>(); var routeService = services.GetRequiredService<TemplateRouteService>();
@@ -686,7 +785,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
@@ -701,6 +800,115 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
} }
[Fact(DisplayName = "Резолв шага с типом руководителя CompanyDirectors резолвится в директора каждой компании инициатора, все с одним OrderNo")]
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_ResolvesOneRowPerCompany()
{
// Arrange — инициатор числится в двух компаниях; для одной из них (KazPromService)
// должность директора захардкожена как «Главный механик», а не «Директор».
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo>
{
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности")
});
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS"));
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("CompanyDirectors request", "/company-directors-request", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.Count.ShouldBe(2);
resolved.Value.Steps.ShouldAllBe(s => s.OrderNo == 5);
resolved.Value.Steps.ShouldAllBe(s => s.PositionCode == HeadType.CompanyDirectors);
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz");
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
}
[Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors, а не конфликтует")]
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors()
{
// Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5)
// и Директор Tansu (из шага DirectorTansu, OrderNo 10, идёт в маршруте последним) —
// один и тот же человек с одним и тем же email.
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EmployeeDirectoryInfo>
{
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности")
});
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS"));
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("CompanyDirectors overlaps DirectorTansu request", "/company-directors-overlaps-director-tansu", null, null, "General"),
CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
CancellationToken.None);
// Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail,
// поэтому HeadType.DirectorTansu резолвится в дефолт из TnsuPortalOptions
// ("director.tansu@tnsu.kz") — тот же email, что вернул GetEmployeeByPositionAsync
// для ТОО TANSU Construction.
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert — вместо трёх строк (2 CompanyDirectors + 1 DirectorTansu) остаётся
// две: KazPromService-директор и Директор Tansu, без предупреждения о конфликте.
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.Count.ShouldBe(2);
var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz");
tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu);
tansuStep.OrderNo.ShouldBe(10);
tansuStep.Warning.ShouldBeNull();
resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors);
}
[Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")] [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")]
public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning() public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning()
{ {
@@ -713,7 +921,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 +950,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 +979,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -59,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -71,20 +71,79 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null); var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None); var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var duplicate = await _service.CreateAsync( var duplicate = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, null, null), new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
duplicate.IsFailed.ShouldBeTrue(); duplicate.IsFailed.ShouldBeTrue();
} }
[Fact(DisplayName = "Порядок условий и логика их объединения не влияют на определение дубликата")]
public async Task CreateAsync_DuplicateStepWithSameConditionsInDifferentOrder_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with duplicate conditions", byProject: false, listName: "Requests");
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false,
new[]
{
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"),
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent")
},
ConditionsLogic.And),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action — те же два условия, в обратном порядке, но с той же логикой И
var duplicate = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false,
new[]
{
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent"),
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000")
},
ConditionsLogic.And),
CancellationToken.None);
// Assert
duplicate.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Те же условия, но с другой логикой объединения (И/ИЛИ), — не дубликат")]
public async Task CreateAsync_SameConditionsWithDifferentLogic_DoesNotConflict()
{
// Arrange
var routeId = await CreateRouteAsync("Route with different conditions logic", byProject: false, listName: "Requests");
var conditions = new[]
{
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"),
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent")
};
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, conditions, ConditionsLogic.And),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action — те же условия, но объединены через ИЛИ, а не И
var second = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, conditions, ConditionsLogic.Or),
CancellationToken.None);
// Assert
second.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")] [Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")]
public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult() public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult()
{ {
@@ -94,14 +153,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief // Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -115,14 +174,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, null, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -138,11 +197,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var chief = await _service.CreateAsync( var chief = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
var directorTansu = await _service.CreateAsync( var directorTansu = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -160,11 +219,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.LessThan, "1000"), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -181,11 +240,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.LessThan, "1000"), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -200,14 +259,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — то же условие, другой статус // Action — то же условие, другой статус
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -221,14 +280,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false); var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId) // Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null), new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -244,7 +303,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -262,7 +321,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -282,7 +341,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -300,7 +359,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -315,14 +374,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -339,14 +398,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step", byProject: false); var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -363,14 +422,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -384,14 +443,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false); var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -405,14 +464,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, Conditions: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -420,16 +479,17 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.Value.ExecutorEmail.ShouldBe("a@b.kz"); patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
} }
[Fact(DisplayName = "Создание шага с частично заполненными полями условия возвращает неуспешный результат")] [Fact(DisplayName = "Создание шага с условием без значения возвращает неуспешный результат")]
public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult() public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests");
// Action // Action — оператор задан, но value пуст: условие наполовину заполнено
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -445,7 +505,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -461,14 +521,15 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
created.Value.FieldName.ShouldBe("Amount"); created.Value.Conditions.ShouldHaveSingleItem();
created.Value.Operation.ShouldBe(ComparisonOperator.GreaterThan); created.Value.Conditions[0].FieldName.ShouldBe("Amount");
created.Value.Value.ShouldBe("1000"); created.Value.Conditions[0].Operation.ShouldBe(ComparisonOperator.GreaterThan);
created.Value.Conditions[0].Value.ShouldBe("1000");
} }
[Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")] [Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")]
@@ -478,14 +539,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: "Amount", Operation: null, Value: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, Conditions: new[] { new RouteStepConditionDto("Amount", ComparisonOperator.None, "") }),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -499,7 +560,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -520,16 +581,16 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false); var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
await _service.CreateAsync( await _service.CreateAsync(
routeBId, routeBId,
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, null, null), new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null),
CancellationToken.None); CancellationToken.None);
// Другой исполнитель — не должен попасть в выдачу. // Другой исполнитель — не должен попасть в выдачу.
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null), new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -44,9 +44,8 @@ function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
headType: step.headType, headType: step.headType,
status: step.status, status: step.status,
allowReassignToOtherApprovers: step.allowReassignToOtherApprovers, allowReassignToOtherApprovers: step.allowReassignToOtherApprovers,
fieldName: step.fieldName, conditions: step.conditions,
operation: step.operation, conditionsLogic: step.conditionsLogic,
value: step.value,
slaHours: step.slaHours, slaHours: step.slaHours,
askOverdueReason: step.askOverdueReason askOverdueReason: step.askOverdueReason
}; };
@@ -1,5 +1,27 @@
@import '../../../../styles/design-tokens'; @import '../../../../styles/design-tokens';
// Список условий появления шага — своя мини-форма после основной сетки полей
// (тот же приём, что и в ResolveScreen для списка проверяемых значений).
.conditions {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid $gray-200;
}
.conditionRow {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.conditionsHint {
margin: -4px 0 10px;
font-size: 11.5px;
color: $gray-400;
}
// Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри // Подсказка «кого найдёт эта должность» под полем «Должность». Живёт внутри
// Field, поэтому отступ сверху небольшой — визуально это продолжение поля, // Field, поэтому отступ сверху небольшой — визуально это продолжение поля,
// а не отдельный блок. // а не отдельный блок.
@@ -2,23 +2,32 @@ import * as React from 'react';
import styles from './RouteStepFormModal.module.scss'; import styles from './RouteStepFormModal.module.scss';
import { import {
ComparisonOperator, ComparisonOperator,
ConditionsLogic,
Employee, Employee,
HeadType, HeadType,
ProjectRole, ProjectRole,
RequestType, RequestType,
RouteStepCondition,
RouteStepStatus, RouteStepStatus,
StepExecutorKind, StepExecutorKind,
TemplateRoute, TemplateRoute,
TemplateRouteStep TemplateRouteStep
} from '../../models'; } from '../../models';
import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import {
comparisonOperatorLabels,
conditionsLogicLabels,
headTypeLabels,
projectRoleLabels,
routeStepStatusLabels,
texts
} from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee'; import { matchEmployeesByPosition, toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields'; import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists'; import { isNumericFieldType, SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; import { Button, Checkbox, Field, FormGrid, FormModal, IconButton, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
import { import {
boolToTriState, boolToTriState,
isValidSlaHours, isValidSlaHours,
@@ -63,14 +72,15 @@ interface IFormState {
headType: HeadType; headType: HeadType;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
hasCondition: boolean; hasCondition: boolean;
fieldName: string; conditions: RouteStepCondition[];
operation: ComparisonOperator; conditionsLogic: ConditionsLogic;
value: string;
/** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */ /** Пусто — наследовать SLA от типа заявки; «0» — напоминать сразу. Это разные вещи. */
slaHours: string; slaHours: string;
askOverdueReason: TriState; askOverdueReason: TriState;
} }
const EMPTY_CONDITION: RouteStepCondition = { fieldName: '', operation: ComparisonOperator.GreaterThan, value: '' };
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
.map(v => ({ value: v, label: routeStepStatusLabels[v] })); .map(v => ({ value: v, label: routeStepStatusLabels[v] }));
@@ -86,6 +96,8 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None) .filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] })); .map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
const CONDITIONS_LOGIC_OPTIONS = Object.values(ConditionsLogic).map(v => ({ value: v, label: conditionsLogicLabels[v] }));
/** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */ /** «Больше»/«меньше» сравнивают числа — для текстовой колонки они бессмысленны. */
const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan]; const NUMERIC_ONLY_OPERATIONS: ComparisonOperator[] = [ComparisonOperator.GreaterThan, ComparisonOperator.LessThan];
@@ -118,9 +130,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
headType: HeadType.Chief, headType: HeadType.Chief,
allowReassignToOtherApprovers: false, allowReassignToOtherApprovers: false,
hasCondition: false, hasCondition: false,
fieldName: '', conditions: [EMPTY_CONDITION],
operation: ComparisonOperator.GreaterThan, conditionsLogic: ConditionsLogic.And,
value: '',
slaHours: '', slaHours: '',
askOverdueReason: '' askOverdueReason: ''
}; };
@@ -134,10 +145,9 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers, allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName), hasCondition: editing.conditions.length > 0,
fieldName: editing.fieldName ?? '', conditions: editing.conditions.length > 0 ? editing.conditions : [EMPTY_CONDITION],
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan, conditionsLogic: editing.conditionsLogic,
value: editing.value ?? '',
slaHours: slaHoursToInput(editing.slaHours), slaHours: slaHoursToInput(editing.slaHours),
askOverdueReason: boolToTriState(editing.askOverdueReason) askOverdueReason: boolToTriState(editing.askOverdueReason)
}; };
@@ -162,59 +172,61 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
const hasListId = Boolean(requestType?.listId); const hasListId = Boolean(requestType?.listId);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined);
const fieldNameOptions = React.useMemo(() => { // currentValue — значение конкретной строки условия: если оно не входит в
// подгруженные поля списка (набрано до появления поля в списке или после его
// удаления), добавляем его отдельным пунктом, чтобы не сбрасывать выбор молча.
const fieldNameOptionsFor = (currentValue: string): { value: string; label: string }[] => {
const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions]; const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions];
if (form.fieldName && !options.some(o => o.value === form.fieldName)) { if (currentValue && !options.some(o => o.value === currentValue)) {
options.push({ value: form.fieldName, label: form.fieldName }); options.push({ value: currentValue, label: currentValue });
} }
return options; return options;
}, [sharePointFields.items, form.fieldName]); };
const isManagerField = form.fieldName === IS_MANAGER_FIELD;
// Тип столбца известен, только если он есть среди подгруженных полей списка — // Тип столбца известен, только если он есть среди подгруженных полей списка —
// для значения, введённого до появления поля в списке (или после его удаления), // для значения, введённого до появления поля в списке (или после его удаления),
// ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные. // ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные.
// IsManager — служебное булево поле, а не колонка списка, поэтому для него // IsManager — служебное булево поле, а не колонка списка, поэтому для него
// числовые ограничения не действуют в любом случае. // числовые ограничения не действуют в любом случае.
const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName); const fieldKindOf = (fieldName: string): 'bool' | 'numeric' | 'text' | 'unknown' => {
const fieldKind: 'bool' | 'numeric' | 'text' | 'unknown' = isManagerField if (fieldName === IS_MANAGER_FIELD) return 'bool';
? 'bool' const field = sharePointFields.items.find(f => f.internalName === fieldName);
: !selectedField if (!field) return 'unknown';
? 'unknown' return isNumericFieldType(field.typeAsString) ? 'numeric' : 'text';
: isNumericFieldType(selectedField.typeAsString) };
? 'numeric' const isNumericFieldKind = (kind: ReturnType<typeof fieldKindOf>): boolean => kind === 'numeric' || kind === 'unknown';
: 'text';
const isNumericField = fieldKind === 'numeric' || fieldKind === 'unknown';
// Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше». // Числовая колонка: всё, кроме «содержит»/«не содержит». Текстовая: всё, кроме «больше»/«меньше».
// Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем. // Булев IsManager: только «равно»/«не равно». Неизвестный тип — не ограничиваем.
const operationOptions = React.useMemo(() => OPERATION_OPTIONS.filter(o => { const operationOptionsFor = (kind: ReturnType<typeof fieldKindOf>): typeof OPERATION_OPTIONS =>
if (fieldKind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual; OPERATION_OPTIONS.filter(o => {
if (fieldKind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0; if (kind === 'bool') return o.value === ComparisonOperator.Equal || o.value === ComparisonOperator.NotEqual;
if (fieldKind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0; if (kind === 'numeric') return TEXT_ONLY_OPERATIONS.indexOf(o.value) < 0;
if (kind === 'text') return NUMERIC_ONLY_OPERATIONS.indexOf(o.value) < 0;
return true; return true;
}), [fieldKind]); });
// Оператор, недопустимый для выбранной колонки (например, «больше» после смены const updateCondition = (index: number, patch: Partial<RouteStepCondition>): void => {
setForm(f => ({
...f,
conditions: f.conditions.map((c, i) => {
if (i !== index) return c;
const next = { ...c, ...patch };
// Оператор, недопустимый для новой колонки (например, «больше» после смены
// field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда. // field_name на текстовую), сбрасываем на «равно» — оно допустимо всегда.
const isOperationAllowed = operationOptions.some(o => o.value === form.operation); const kind = fieldKindOf(next.fieldName);
React.useEffect(() => { if (!operationOptionsFor(kind).some(o => o.value === next.operation)) next.operation = ComparisonOperator.Equal;
if (!isOperationAllowed) {
setForm(f => ({ ...f, operation: ComparisonOperator.Equal }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOperationAllowed]);
// IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS,
// а не произвольным текстом, который пользователь мог набрать для другого field_name. // а не произвольным текстом, который пользователь мог набрать для другого field_name.
React.useEffect(() => { if (kind === 'bool' && next.value !== 'true' && next.value !== 'false') next.value = 'true';
if (isManagerField && form.value !== 'true' && form.value !== 'false') { return next;
setForm(f => ({ ...f, value: 'true' })); })
} }));
// eslint-disable-next-line react-hooks/exhaustive-deps };
}, [isManagerField]); const addCondition = (): void => setForm(f => ({ ...f, conditions: [...f.conditions, EMPTY_CONDITION] }));
const removeCondition = (index: number): void =>
setForm(f => ({ ...f, conditions: f.conditions.filter((_, i) => i !== index) }));
// Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ // Подсказка «кого найдёт эта должность»: бэкенд на резолве ищет ТОЧНОЕ
// совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо // совпадение должности в справочнике (/Employee/ByPosition), и опечатка тихо
@@ -336,14 +348,19 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition); submit.fail(texts.routeSteps.validationPosition);
return; return;
} }
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) { if (form.hasCondition) {
for (const c of form.conditions) {
if (!c.fieldName.trim() || !c.value.trim()) {
submit.fail(texts.routeSteps.validationCondition); submit.fail(texts.routeSteps.validationCondition);
return; return;
} }
if (form.hasCondition && isNumericField && !Number.isFinite(Number(form.value.trim()))) { const kind = fieldKindOf(c.fieldName);
submit.fail(texts.routeSteps.validationConditionNumber); if (isNumericFieldKind(kind) && !Number.isFinite(Number(c.value.trim()))) {
submit.fail(texts.routeSteps.validationConditionNumber(c.fieldName));
return; return;
} }
}
}
if (!isValidSlaHours(form.slaHours)) { if (!isValidSlaHours(form.slaHours)) {
submit.fail(texts.routeSteps.validationSlaHours); submit.fail(texts.routeSteps.validationSlaHours);
return; return;
@@ -357,9 +374,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
position: form.kind === 'position' ? form.position.trim() : null, position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null, headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null, conditions: form.hasCondition
operation: form.hasCondition ? form.operation : null, ? form.conditions.map(c => ({ fieldName: c.fieldName.trim(), operation: c.operation, value: c.value.trim() }))
value: form.hasCondition ? form.value.trim() : null, : null,
conditionsLogic: form.conditionsLogic,
slaHours: parseSlaHours(form.slaHours), slaHours: parseSlaHours(form.slaHours),
askOverdueReason: triStateToBool(form.askOverdueReason) askOverdueReason: triStateToBool(form.askOverdueReason)
}; };
@@ -495,17 +513,29 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
/> />
</Field> </Field>
</FormGrid>
{form.hasCondition && ( {form.hasCondition && (
<> <div className={styles.conditions}>
<Field {form.conditions.length > 1 && (
label={texts.routeSteps.conditionFieldNameLabel} <Field label={texts.routeSteps.conditionLogicLabel}>
required <Select
full options={CONDITIONS_LOGIC_OPTIONS}
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined} value={form.conditionsLogic}
> onChange={e => setForm({ ...form, conditionsLogic: e.target.value as ConditionsLogic })}
/>
</Field>
)}
{form.conditions.map((condition, index) => {
const kind = fieldKindOf(condition.fieldName);
const fieldNameOptions = fieldNameOptionsFor(condition.fieldName);
return (
// eslint-disable-next-line react/no-array-index-key -- строки не переупорядочиваются, только добавляются/удаляются с конца или явным индексом
<div className={styles.conditionRow} key={index}>
<Select <Select
options={fieldNameOptions} options={fieldNameOptions}
value={form.fieldName} value={condition.fieldName}
disabled={sharePointFields.isLoading} disabled={sharePointFields.isLoading}
placeholder={ placeholder={
sharePointFields.isLoading sharePointFields.isLoading
@@ -514,35 +544,44 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
? texts.routeSteps.conditionFieldNameEmpty ? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder : texts.routeSteps.conditionFieldNamePlaceholder
} }
onChange={e => setForm({ ...form, fieldName: e.target.value })} onChange={e => updateCondition(index, { fieldName: e.target.value })}
/> />
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select <Select
options={operationOptions} options={operationOptionsFor(kind)}
value={form.operation} value={condition.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })} onChange={e => updateCondition(index, { operation: e.target.value as ComparisonOperator })}
/> />
</Field> {kind === 'bool' ? (
<Field label={texts.routeSteps.conditionValueLabel} required>
{isManagerField ? (
<Select <Select
options={IS_MANAGER_VALUE_OPTIONS} options={IS_MANAGER_VALUE_OPTIONS}
value={form.value} value={condition.value}
onChange={e => setForm({ ...form, value: e.target.value })} onChange={e => updateCondition(index, { value: e.target.value })}
/> />
) : ( ) : (
<TextInput <TextInput
type={isNumericField ? 'number' : 'text'} type={isNumericFieldKind(kind) ? 'number' : 'text'}
value={form.value} value={condition.value}
onChange={e => setForm({ ...form, value: e.target.value })} onChange={e => updateCondition(index, { value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder} placeholder={texts.routeSteps.conditionValuePlaceholder}
/> />
)} )}
</Field> <IconButton
</> icon="trash"
label={texts.routeSteps.conditionRemove}
danger
disabled={form.conditions.length === 1}
onClick={() => removeCondition(index)}
/>
</div>
);
})}
{sharePointFields.error && <div className={styles.conditionsHint}>{texts.routeSteps.conditionFieldNameError(sharePointFields.error)}</div>}
<Button variant="secondary" size="sm" icon="plus" onClick={addCondition}>
{texts.routeSteps.conditionAdd}
</Button>
</div>
)} )}
</FormGrid>
</FormModal> </FormModal>
); );
}; };
@@ -26,7 +26,11 @@ export enum HeadType {
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery), // Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на // а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
// экране «Проверка маршрута» появляется отдельное поле выбора отдела. // экране «Проверка маршрута» появляется отдельное поле выбора отдела.
DepartmentHead = 'DepartmentHead' DepartmentHead = 'DepartmentHead',
// Единственное значение, которое резолвится в НЕСКОЛЬКО строк результата — по
// директору на каждую компанию инициатора (сотрудник может числиться в
// нескольких), все с одним и тем же orderNo (см. TemplateRouteService.ResolveStepAsync).
CompanyDirectors = 'CompanyDirectors'
} }
export enum ComparisonOperator { export enum ComparisonOperator {
@@ -40,3 +44,9 @@ export enum ComparisonOperator {
Contains = 'Contains', Contains = 'Contains',
NotContains = 'NotContains' NotContains = 'NotContains'
} }
/** Как объединяются условия шага между собой — не действует, если условие одно или их нет. */
export enum ConditionsLogic {
And = 'And',
Or = 'Or'
}
@@ -2,7 +2,7 @@
// (Application/TemplateRoutes/ResolveTemplateRouteDto.cs). // (Application/TemplateRoutes/ResolveTemplateRouteDto.cs).
/* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */ /* eslint-disable @rushstack/no-new-null -- поля 1:1 зеркалят nullable JSON-контракт бэкенда */
import { RouteStepStatus } from './enums'; import { HeadType, RouteStepStatus } from './enums';
/** Значение поля списка SharePoint (field_name шага) для проверки условий шагов. */ /** Значение поля списка SharePoint (field_name шага) для проверки условий шагов. */
export interface ResolveFieldValue { export interface ResolveFieldValue {
@@ -28,6 +28,12 @@ export interface ResolvedRouteStep {
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
/** Причина, по которой исполнитель шага не определился (текст с бэкенда). */ /** Причина, по которой исполнитель шага не определился (текст с бэкенда). */
warning: string | null; warning: string | null;
/**
* HeadType шага (Chief/DirectionHead/...), если исполнитель задан типом
* руководителя, иначе null. В отличие от position (реальная должность
* резолвнутого сотрудника из справочника) — это код роли из самого шага.
*/
positionCode: HeadType | null;
} }
export interface ResolveTemplateRouteResult { export interface ResolveTemplateRouteResult {
@@ -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;
} }
+20 -4
View File
@@ -4,7 +4,7 @@
// //
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались. // Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './models'; import { ComparisonOperator, ConditionsLogic, HeadType, ProjectRole, RouteStepStatus } from './models';
import { pluralRu } from './utils/plural'; import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ---- // ---- Подписи enum'ов ----
@@ -32,7 +32,8 @@ export const headTypeLabels: Record<HeadType, string> = {
[HeadType.DirectionHead]: 'Руководитель направления', [HeadType.DirectionHead]: 'Руководитель направления',
[HeadType.DirectorTansu]: 'Директор Tansu', [HeadType.DirectorTansu]: 'Директор Tansu',
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu', [HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
[HeadType.DepartmentHead]: 'Руководитель указанного отдела' [HeadType.DepartmentHead]: 'Руководитель указанного отдела',
[HeadType.CompanyDirectors]: 'Директора компаний инициатора'
}; };
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = { export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
@@ -55,6 +56,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 +342,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);
} }