правки

This commit is contained in:
gorbenko.pavel
2026-07-24 17:54:34 +05:00
parent 3ae1965c29
commit 5928377465
4 changed files with 88 additions and 12 deletions
@@ -16,8 +16,8 @@ public sealed record TemplateRouteStepDto(
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
string ExecutorEmail, string? ExecutorEmail,
string Position, string? Position,
ProjectRole ProjectRole, ProjectRole ProjectRole,
HeadType HeadType, HeadType HeadType,
RouteStepStatus Status, RouteStepStatus Status,
@@ -25,8 +25,8 @@ public sealed record CreateTemplateRouteStepRequest(
public sealed record UpdateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
string ExecutorEmail, string? ExecutorEmail,
string Position, string? Position,
ProjectRole ProjectRole, ProjectRole ProjectRole,
HeadType HeadType, HeadType HeadType,
RouteStepStatus Status, RouteStepStatus Status,
@@ -41,12 +41,21 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
var executorEmail = r.ExecutorEmail ?? string.Empty;
var position = r.Position ?? string.Empty;
var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
var entity = new TemplateRouteStep var entity = new TemplateRouteStep
{ {
RouteId = route.Id, RouteId = route.Id,
OrderNo = r.OrderNo, OrderNo = r.OrderNo,
ExecutorEmail = r.ExecutorEmail, ExecutorEmail = executorEmail,
Position = r.Position, Position = position,
ProjectRole = r.ProjectRole, ProjectRole = r.ProjectRole,
HeadType = r.HeadType, HeadType = r.HeadType,
Status = r.Status, Status = r.Status,
@@ -73,9 +82,18 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
var executorEmail = r.ExecutorEmail ?? string.Empty;
var position = r.Position ?? string.Empty;
var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
entity.OrderNo = r.OrderNo; entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail; entity.ExecutorEmail = executorEmail;
entity.Position = r.Position; entity.Position = position;
entity.ProjectRole = r.ProjectRole; entity.ProjectRole = r.ProjectRole;
entity.HeadType = r.HeadType; entity.HeadType = r.HeadType;
entity.Status = r.Status; entity.Status = r.Status;
@@ -97,6 +115,19 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok(); return Result.Ok();
} }
private Task<bool> HasDuplicateAsync(
int routeId, string executorEmail, string position, ProjectRole projectRole, HeadType headType,
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRouteSteps.AnyAsync(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
x.ExecutorEmail == executorEmail &&
x.Position == position &&
x.ProjectRole == projectRole &&
x.HeadType == headType &&
x.Status == status,
cancellationToken);
// Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning. // Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning.
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync( private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
@@ -8,8 +8,12 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
public CreateTemplateRouteStepRequestValidator() public CreateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x) RuleFor(x => x)
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) .Must(x =>
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); !string.IsNullOrWhiteSpace(x.ExecutorEmail) ||
!string.IsNullOrWhiteSpace(x.Position) ||
x.ProjectRole != ProjectRole.None ||
x.HeadType != HeadType.None)
.WithMessage("Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
} }
} }
@@ -18,7 +22,11 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
public UpdateTemplateRouteStepRequestValidator() public UpdateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x) RuleFor(x => x)
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) .Must(x =>
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); !string.IsNullOrWhiteSpace(x.ExecutorEmail) ||
!string.IsNullOrWhiteSpace(x.Position) ||
x.ProjectRole != ProjectRole.None ||
x.HeadType != HeadType.None)
.WithMessage("Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
} }
} }
@@ -65,6 +65,43 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
} }
[Fact(DisplayName = "Повторное создание шага с теми же исполнителем, должностью, ролью, типом руководителя и статусом возвращает конфликт")]
public async Task CreateAsync_DuplicateStep_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action
var duplicate = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, true),
CancellationToken.None);
// Assert
duplicate.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
{
// Arrange
var routeId = await CreateRouteAsync("Head type only route", byProject: false);
// Action
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.HeadType.ShouldBe(HeadType.Chief);
created.Value.ExecutorEmail.ShouldBe(string.Empty);
}
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")] [Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep() public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
{ {