поправлены дубли шагов
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 1m59s

This commit is contained in:
gorbenko.pavel
2026-08-11 10:07:41 +05:00
parent 0dedabc2ae
commit 509f26de7b
2 changed files with 101 additions and 18 deletions
@@ -51,14 +51,15 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: null, cancellationToken))
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим."));
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep
{
@@ -104,14 +105,15 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: entity.Id, cancellationToken))
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим."));
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
@@ -168,14 +170,15 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
route.Id, executorEmail, position, projectRole, headType, status,
fieldName, operation, value, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, headType, excludeId: entity.Id, cancellationToken))
if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» — второй такой шаг недопустим."));
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail;
@@ -207,9 +210,16 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) =>
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
/// <summary>
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
/// условии появления (field_name/operation/value): шаги с разными условиями
/// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут,
/// а в один резолв — нет.
/// </summary>
private Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value,
int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRouteSteps.AnyAsync(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
@@ -217,7 +227,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
x.Position == position &&
x.ProjectRole == projectRole &&
x. HeadType == headType &&
x.Status == status,
x.Status == status &&
x.FieldName == fieldName &&
x.Operation == operation &&
x.Value == value,
cancellationToken);
/// <summary>
@@ -229,11 +242,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
/// в одного и того же (или неотличимо какого) руководителя — например, «два
/// разных директора одной и той же компании» просто не существуют. В отличие
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
/// достаточно самого факта совпадения HeadType — вне зависимости от статуса
/// или остальных полей шага.
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных
/// полей шага. Исключение — условие появления шага: при разных условиях
/// в конкретный резолв попадёт только один из шагов, так что одного и того же
/// руководителя дважды в маршруте не окажется.
/// </summary>
private Task<bool> HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, int? excludeId, CancellationToken cancellationToken)
int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value,
int? excludeId, CancellationToken cancellationToken)
{
if (headType is null or HeadType.None)
return Task.FromResult(false);
@@ -241,7 +257,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return db.TemplateRouteSteps.AnyAsync(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
x.HeadType == headType,
x.HeadType == headType &&
x.FieldName == fieldName &&
x.Operation == operation &&
x.Value == value,
cancellationToken);
}
@@ -150,6 +150,70 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
directorTansu.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Два шага с одним типом руководителя, но разными условиями появления — допустимы")]
public async Task CreateAsync_SameHeadTypeDifferentConditions_BothSucceed()
{
// Arrange — условия появления взаимоисключающи по данным заявки, поэтому
// в конкретный резолв попадёт только один из двух шагов.
var routeId = await CreateRouteAsync("Route with head type per condition", byProject: false, listName: "Requests");
// Action
var small = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.LessThan, "1000"),
CancellationToken.None);
var big = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
small.IsSuccess.ShouldBeTrue();
big.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Два шага с одним конкретным исполнителем, но разными условиями появления — допустимы")]
public async Task CreateAsync_SameExecutorDifferentConditions_BothSucceed()
{
// Arrange
var routeId = await CreateRouteAsync("Route with executor per condition", byProject: false, listName: "Requests");
// Action
var small = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.LessThan, "1000"),
CancellationToken.None);
var big = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
small.IsSuccess.ShouldBeTrue();
big.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Два шага с одним типом руководителя и одинаковым условием появления — конфликт")]
public async Task CreateAsync_SameHeadTypeSameCondition_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action — то же условие, другой статус
var second = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
second.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Обновление шага, при котором HeadType не меняется, не конфликтует само с собой")]
public async Task UpdateAsync_SameHeadTypeOnSameStep_DoesNotConflictWithItself()
{