From 13f557e1f4628ccb3acdc8985b0aa42e874817e3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 27 Jul 2026 15:44:25 +0500 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20nullable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRouteStepDto.cs | 16 ++-- .../TemplateRouteStepService.cs | 33 ++++---- .../TemplateRouteStepValidators.cs | 25 ++++--- .../TemplateRoutes/ResolveTemplateRouteDto.cs | 8 +- .../TemplateRoutes/TemplateRouteService.cs | 2 +- .../Entities/TemplateRouteStep.cs | 8 +- .../Persistence/ApprovalRouteDbContext.cs | 8 +- ... 20260727103117_InitialCreate.Designer.cs} | 6 +- ...ate.cs => 20260727103117_InitialCreate.cs} | 8 +- .../ApprovalRouteDbContextModelSnapshot.cs | 4 - .../TemplateRouteServiceTests.cs | 4 +- .../TemplateRouteStepServiceTests.cs | 75 +++++++++++++++---- 12 files changed, 113 insertions(+), 84 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260724114550_InitialCreate.Designer.cs => 20260727103117_InitialCreate.Designer.cs} (97%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260724114550_InitialCreate.cs => 20260727103117_InitialCreate.cs} (95%) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index c28cf0e..ca3074e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -5,10 +5,10 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; public sealed record TemplateRouteStepDto( int Id, int OrderNo, - string ExecutorEmail, - string Position, - ProjectRole ProjectRole, - HeadType HeadType, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, RouteStepStatus Status, int RouteId, bool AllowReassignToOtherApprovers, @@ -18,8 +18,8 @@ public sealed record CreateTemplateRouteStepRequest( int OrderNo, string? ExecutorEmail, string? Position, - ProjectRole ProjectRole, - HeadType HeadType, + ProjectRole? ProjectRole, + HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers); @@ -27,8 +27,8 @@ public sealed record UpdateTemplateRouteStepRequest( int OrderNo, string? ExecutorEmail, string? Position, - ProjectRole ProjectRole, - HeadType HeadType, + ProjectRole? ProjectRole, + HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index be18cb8..d43d3a0 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -37,15 +37,12 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - if (!route.ByProject && r.ProjectRole != ProjectRole.None) + if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (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); + route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); @@ -54,8 +51,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) { RouteId = route.Id, OrderNo = r.OrderNo, - ExecutorEmail = executorEmail, - Position = position, + ExecutorEmail = r.ExecutorEmail, + Position = r.Position, ProjectRole = r.ProjectRole, HeadType = r.HeadType, Status = r.Status, @@ -78,22 +75,19 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Шаг маршрута", id)); - if (!route.ByProject && r.ProjectRole != ProjectRole.None) + if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (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); + route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); entity.OrderNo = r.OrderNo; - entity.ExecutorEmail = executorEmail; - entity.Position = position; + entity.ExecutorEmail = r.ExecutorEmail; + entity.Position = r.Position; entity.ProjectRole = r.ProjectRole; entity.HeadType = r.HeadType; entity.Status = r.Status; @@ -120,14 +114,13 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) var headType = r.HeadType ?? entity.HeadType; var status = r.Status ?? entity.Status; - if (!route.ByProject && projectRole != ProjectRole.None) + if (!route.ByProject && projectRole is not (null or ProjectRole.None)) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); - if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && - projectRole == ProjectRole.None && headType == HeadType.None) + if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1) return Result.Fail(new ValidationError( - "Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); + "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); var duplicate = await HasDuplicateAsync( route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); @@ -160,7 +153,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) } private Task HasDuplicateAsync( - int routeId, string executorEmail, string position, ProjectRole projectRole, HeadType headType, + int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) => db.TemplateRouteSteps.AnyAsync(x => x.Id != excludeId && @@ -179,7 +172,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (route.ByProject) { var hasProjectRoleStep = await db.TemplateRouteSteps - .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken); + .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken); if (!hasProjectRoleStep) warning = "Должен быть хотя бы один шаг с ролью в проекте."; } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 8f5e7a7..37f1620 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -8,12 +8,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< public CreateTemplateRouteStepRequestValidator() { RuleFor(x => x) - .Must(x => - !string.IsNullOrWhiteSpace(x.ExecutorEmail) || - !string.IsNullOrWhiteSpace(x.Position) || - x.ProjectRole != ProjectRole.None || - x.HeadType != HeadType.None) - .WithMessage("Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) + .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); } } @@ -22,11 +18,16 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< public UpdateTemplateRouteStepRequestValidator() { RuleFor(x => x) - .Must(x => - !string.IsNullOrWhiteSpace(x.ExecutorEmail) || - !string.IsNullOrWhiteSpace(x.Position) || - x.ProjectRole != ProjectRole.None || - x.HeadType != HeadType.None) - .WithMessage("Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) + .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); } } + +internal static class TemplateRouteStepFields +{ + public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) => + (string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) + + (string.IsNullOrWhiteSpace(position) ? 0 : 1) + + (projectRole is null or ProjectRole.None ? 0 : 1) + + (headType is null or HeadType.None ? 0 : 1); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 62dba0f..eb9ac43 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -5,10 +5,10 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed record ResolvedRouteStepDto( int Id, int OrderNo, - string ExecutorEmail, - string Position, - ProjectRole ProjectRole, - HeadType HeadType, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 1ca4a9a..b0e4e44 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -187,7 +187,7 @@ public sealed class TemplateRouteService( private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) { - if (step.ProjectRole != ProjectRole.None) + if (step.ProjectRole is not (null or ProjectRole.None)) { var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); if (projectExecutor is not null) diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 75305c9..9901b07 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -7,10 +7,10 @@ public class TemplateRouteStep public int Id { get; set; } public int OrderNo { get; set; } - public string ExecutorEmail { get; set; } = string.Empty; - public string Position { get; set; } = string.Empty; - public ProjectRole ProjectRole { get; set; } = ProjectRole.None; - public HeadType HeadType { get; set; } = HeadType.None; + public string? ExecutorEmail { get; set; } + public string? Position { get; set; } + public ProjectRole? ProjectRole { get; set; } + public HeadType? HeadType { get; set; } public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; public int RouteId { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index a746779..2c84977 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -29,10 +29,10 @@ public class ApprovalRouteDbContext(DbContextOptions opt modelBuilder.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired(); - e.Property(x => x.Position).HasMaxLength(128).IsRequired(); - e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32).IsRequired(); - e.Property(x => x.HeadType).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.ExecutorEmail).HasMaxLength(256); + e.Property(x => x.Position).HasMaxLength(128); + e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32); + e.Property(x => x.HeadType).HasConversion().HasMaxLength(32); e.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasIndex(x => new { x.RouteId, x.OrderNo }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs similarity index 97% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs index 3c494dd..e977bc9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260724114550_InitialCreate")] + [Migration("20260727103117_InitialCreate")] partial class InitialCreate { /// @@ -111,13 +111,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("allow_reassign_to_other_approvers"); b.Property("ExecutorEmail") - .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("executor_email"); b.Property("HeadType") - .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("head_type"); @@ -127,13 +125,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("order_no"); b.Property("Position") - .IsRequired() .HasMaxLength(128) .HasColumnType("character varying(128)") .HasColumnName("position"); b.Property("ProjectRole") - .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("project_role"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs similarity index 95% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs index b442f61..bc8ec2b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260727103117_InitialCreate.cs @@ -57,10 +57,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), order_no = table.Column(type: "integer", nullable: false), - executor_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), - position = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), - project_role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - head_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + executor_email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + position = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + project_role = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + head_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), route_id = table.Column(type: "integer", nullable: false), allow_reassign_to_other_approvers = table.Column(type: "boolean", nullable: false) diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 5eaa7cb..5b2cea2 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -108,13 +108,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("allow_reassign_to_other_approvers"); b.Property("ExecutorEmail") - .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)") .HasColumnName("executor_email"); b.Property("HeadType") - .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("head_type"); @@ -124,13 +122,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("order_no"); b.Property("Position") - .IsRequired() .HasMaxLength(128) .HasColumnType("character varying(128)") .HasColumnName("position"); b.Property("ProjectRole") - .IsRequired() .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("project_role"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 165947b..0469a9f 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -160,7 +160,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", null, HeadType.Chief, RouteStepStatus.Approval, false), CancellationToken.None); // Action @@ -187,7 +187,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), CancellationToken.None); // Action diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 3a856e1..02ea273 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -42,7 +42,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture