From ccba53cc94a690a62e285619ab9fe4fea5ae8256 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 12:36:56 +0500 Subject: [PATCH 01/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs | 2 +- .../Entities/{Route.cs => TemplateRoute.cs} | 4 ++-- .../Entities/{RouteStep.cs => TemplateRouteStep.cs} | 4 ++-- .../Persistence/ApprovalRouteDbContext.cs | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Domain/Entities/{Route.cs => TemplateRoute.cs} (83%) rename backend/src/Tnsu.ApprovalRoute.Domain/Entities/{RouteStep.cs => TemplateRouteStep.cs} (89%) diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index 2cd90e7..df43cbb 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -9,5 +9,5 @@ public class RequestType public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; - public ICollection Routes { get; set; } = new List(); + public ICollection Routes { get; set; } = new List(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs similarity index 83% rename from backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs rename to backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index 2df9be2..6d3d941 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/Route.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -2,7 +2,7 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Domain.Entities; -public class Route +public class TemplateRoute { public Guid Id { get; set; } = Guid.NewGuid(); public RouteType RouteType { get; set; } = RouteType.Sequential; @@ -16,5 +16,5 @@ public class Route public bool RequiresControl { get; set; } public RequestType? RequestType { get; set; } - public ICollection Steps { get; set; } = new List(); + public ICollection Steps { get; set; } = new List(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs similarity index 89% rename from backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs rename to backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index a70ef8c..098ea8a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -2,7 +2,7 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Domain.Entities; -public class RouteStep +public class TemplateRouteStep { public Guid Id { get; set; } = Guid.NewGuid(); public int OrderNo { get; set; } @@ -16,5 +16,5 @@ public class RouteStep public Guid RouteId { get; set; } public bool AllowReassignToOtherApprovers { get; set; } - public Route? Route { get; set; } + public TemplateRoute? Route { get; set; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 080ae1f..4b57367 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); - public DbSet Routes => Set(); - public DbSet RouteSteps => Set(); + public DbSet Routes => Set(); + public DbSet RouteSteps => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -18,14 +18,14 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); }); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); }); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(x => x.Id); e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired(); -- 2.34.1 From 719cd8139148312fc93d27869fd8f1f78a8dbbe7 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 12:50:31 +0500 Subject: [PATCH 02/38] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entities/TemplateRoute.cs | 2 +- .../Entities/TemplateRouteStep.cs | 3 +- .../Enums/HeadType.cs | 8 ++++ .../Enums/RouteStepStatus.cs | 9 +--- .../Persistence/ApprovalRouteDbContext.cs | 6 ++- ... 20260723074943_InitialCreate.Designer.cs} | 41 +++++++++++-------- ...ate.cs => 20260723074943_InitialCreate.cs} | 32 ++++++++------- .../ApprovalRouteDbContextModelSnapshot.cs | 39 ++++++++++-------- 8 files changed, 81 insertions(+), 59 deletions(-) create mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260722130455_InitialCreate.Designer.cs => 20260723074943_InitialCreate.Designer.cs} (83%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260722130455_InitialCreate.cs => 20260723074943_InitialCreate.cs} (77%) diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index 6d3d941..e400384 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -9,7 +9,7 @@ public class TemplateRoute public Guid RequestTypeId { get; set; } // Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД. - public Guid DepartmentId { get; set; } + public Guid? DepartmentId { get; set; } public Guid CompanyId { get; set; } public bool ByProject { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 098ea8a..b9590b4 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -11,8 +11,9 @@ public class TemplateRouteStep 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 RouteStepStatus Status { get; set; } = RouteStepStatus.Draft; + public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; public Guid RouteId { get; set; } public bool AllowReassignToOtherApprovers { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs new file mode 100644 index 0000000..8643b83 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -0,0 +1,8 @@ +namespace Tnsu.ApprovalRoute.Domain.Enums; + +public enum HeadType +{ + None, + Chief, + DirectionHead +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs index 77d1367..234f672 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteStepStatus.cs @@ -3,11 +3,6 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; public enum RouteStepStatus { None, - Draft, - OnApproval, - Approved, - InExecution, - Completed, - Returned, - Closed + Approval, + Execution } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 4b57367..a746779 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence; public class ApprovalRouteDbContext(DbContextOptions options) : DbContext(options) { public DbSet RequestTypes => Set(); - public DbSet Routes => Set(); - public DbSet RouteSteps => Set(); + public DbSet TemplateRoutes => Set(); + public DbSet TemplateRouteSteps => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -23,6 +23,7 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.HasKey(x => x.Id); e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); + e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique(); }); modelBuilder.Entity(e => @@ -31,6 +32,7 @@ public class ApprovalRouteDbContext(DbContextOptions opt 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.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/20260722130455_InitialCreate.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.Designer.cs similarity index 83% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.Designer.cs index 475d855..5e82c93 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260722130455_InitialCreate")] + [Migration("20260723074943_InitialCreate")] partial class InitialCreate { /// @@ -50,7 +50,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -65,7 +65,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("uuid") .HasColumnName("company_id"); - b.Property("DepartmentId") + b.Property("DepartmentId") .HasColumnType("uuid") .HasColumnName("department_id"); @@ -84,15 +84,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("route_type"); b.HasKey("Id") - .HasName("pk_routes"); + .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId") - .HasDatabaseName("ix_routes_request_type_id"); + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); - b.ToTable("routes", (string)null); + b.ToTable("template_routes", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -109,6 +110,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("executor_email"); + b.Property("HeadType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); @@ -136,34 +143,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("status"); b.HasKey("Id") - .HasName("pk_route_steps"); + .HasName("pk_template_route_steps"); b.HasIndex("RouteId", "OrderNo") - .HasDatabaseName("ix_route_steps_route_id_order_no"); + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); - b.ToTable("route_steps", (string)null); + b.ToTable("template_route_steps", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + 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_routes_request_types_request_type_id"); + .HasConstraintName("fk_template_routes_request_types_request_type_id"); b.Navigation("RequestType"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route") + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") .WithMany("Steps") .HasForeignKey("RouteId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_route_steps_routes_route_id"); + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); b.Navigation("Route"); }); @@ -173,7 +180,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("Routes"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Navigation("Steps"); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.cs similarity index 77% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.cs index 772cbab..c9fede9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260722130455_InitialCreate.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.cs @@ -25,22 +25,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations }); migrationBuilder.CreateTable( - name: "routes", + name: "template_routes", columns: table => new { id = table.Column(type: "uuid", nullable: false), route_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), request_type_id = table.Column(type: "uuid", nullable: false), - department_id = table.Column(type: "uuid", nullable: false), + department_id = table.Column(type: "uuid", nullable: true), company_id = table.Column(type: "uuid", nullable: false), by_project = table.Column(type: "boolean", nullable: false), requires_control = table.Column(type: "boolean", nullable: false) }, constraints: table => { - table.PrimaryKey("pk_routes", x => x.id); + table.PrimaryKey("pk_template_routes", x => x.id); table.ForeignKey( - name: "fk_routes_request_types_request_type_id", + name: "fk_template_routes_request_types_request_type_id", column: x => x.request_type_id, principalTable: "request_types", principalColumn: "id", @@ -48,7 +48,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations }); migrationBuilder.CreateTable( - name: "route_steps", + name: "template_route_steps", columns: table => new { id = table.Column(type: "uuid", nullable: false), @@ -56,40 +56,42 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations 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), status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), route_id = table.Column(type: "uuid", nullable: false), allow_reassign_to_other_approvers = table.Column(type: "boolean", nullable: false) }, constraints: table => { - table.PrimaryKey("pk_route_steps", x => x.id); + table.PrimaryKey("pk_template_route_steps", x => x.id); table.ForeignKey( - name: "fk_route_steps_routes_route_id", + name: "fk_template_route_steps_template_routes_route_id", column: x => x.route_id, - principalTable: "routes", + principalTable: "template_routes", principalColumn: "id", onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateIndex( - name: "ix_route_steps_route_id_order_no", - table: "route_steps", + name: "ix_template_route_steps_route_id_order_no", + table: "template_route_steps", columns: new[] { "route_id", "order_no" }); migrationBuilder.CreateIndex( - name: "ix_routes_request_type_id", - table: "routes", - column: "request_type_id"); + name: "ix_template_routes_request_type_id_department_id_company_id_by", + table: "template_routes", + columns: new[] { "request_type_id", "department_id", "company_id", "by_project" }, + unique: true); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( - name: "route_steps"); + name: "template_route_steps"); migrationBuilder.DropTable( - name: "routes"); + name: "template_routes"); migrationBuilder.DropTable( name: "request_types"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 26f31c4..c2cea76 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -47,7 +47,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.ToTable("request_types", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -62,7 +62,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("uuid") .HasColumnName("company_id"); - b.Property("DepartmentId") + b.Property("DepartmentId") .HasColumnType("uuid") .HasColumnName("department_id"); @@ -81,15 +81,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("route_type"); b.HasKey("Id") - .HasName("pk_routes"); + .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId") - .HasDatabaseName("ix_routes_request_type_id"); + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); - b.ToTable("routes", (string)null); + b.ToTable("template_routes", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -106,6 +107,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("executor_email"); + b.Property("HeadType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); @@ -133,34 +140,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnName("status"); b.HasKey("Id") - .HasName("pk_route_steps"); + .HasName("pk_template_route_steps"); b.HasIndex("RouteId", "OrderNo") - .HasDatabaseName("ix_route_steps_route_id_order_no"); + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); - b.ToTable("route_steps", (string)null); + b.ToTable("template_route_steps", (string)null); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + 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_routes_request_types_request_type_id"); + .HasConstraintName("fk_template_routes_request_types_request_type_id"); b.Navigation("RequestType"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route") + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") .WithMany("Steps") .HasForeignKey("RouteId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_route_steps_routes_route_id"); + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); b.Navigation("Route"); }); @@ -170,7 +177,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.Navigation("Routes"); }); - modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { b.Navigation("Steps"); }); -- 2.34.1 From 1a5ed34182bfa7ada572a7519ec18558d762c9ae Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 13:55:53 +0500 Subject: [PATCH 03/38] =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BA=D1=80=D1=83=D0=B4=20=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2-?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 + .../Common/ResultExtensions.cs | 33 +++++ .../Controllers/RequestTypesController.cs | 31 ++++ .../TemplateRouteStepsController.cs | 31 ++++ .../Controllers/TemplateRoutesController.cs | 31 ++++ backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 4 + .../Common/Errors/ApiErrors.cs | 12 ++ .../RequestTypes/RequestTypeCommands.cs | 75 ++++++++++ .../RequestTypes/RequestTypeDto.cs | 7 + .../RequestTypes/RequestTypeQueries.cs | 39 +++++ .../TemplateRouteStepCommands.cs | 134 ++++++++++++++++++ .../TemplateRouteStepDto.cs | 32 +++++ .../TemplateRouteStepQueries.cs | 41 ++++++ .../TemplateRoutes/TemplateRouteCommands.cs | 122 ++++++++++++++++ .../TemplateRoutes/TemplateRouteDto.cs | 27 ++++ .../TemplateRoutes/TemplateRouteQueries.cs | 43 ++++++ .../Tnsu.ApprovalRoute.Application.csproj | 3 + .../Tnsu.ApprovalRoute.Infrastructure.csproj | 1 - 18 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 0217988..30eec15 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..f440b19 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs @@ -0,0 +1,33 @@ +using FluentResults; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Application.Common.Errors; + +namespace Tnsu.ApprovalRoute.Api.Common; + +public static class ResultExtensions +{ + public static IActionResult ToActionResult(this Result result) + { + if (result.IsFailed) + return result.Errors[0].ToActionResult(); + + var warnings = result.Reasons.OfType().Select(w => w.Message).ToList(); + return new OkObjectResult(new { data = result.Value, warnings }); + } + + public static IActionResult ToActionResult(this Result result) + { + if (result.IsFailed) + return result.Errors[0].ToActionResult(); + + return new NoContentResult(); + } + + private static IActionResult ToActionResult(this IError error) => error switch + { + NotFoundError => new NotFoundObjectResult(new { message = error.Message }), + ValidationError => new BadRequestObjectResult(new { message = error.Message }), + ConflictError => new ConflictObjectResult(new { message = error.Message }), + _ => new ObjectResult(new { message = error.Message }) { StatusCode = StatusCodes.Status500InternalServerError } + }; +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs new file mode 100644 index 0000000..5dac9c2 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.RequestTypes; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +[ApiController] +[Route("api/request-types")] +public sealed class RequestTypesController(ISender mediator) : ControllerBase +{ + [HttpGet] + public async Task List(CancellationToken ct) => + (await mediator.Send(new ListRequestTypesQuery(), ct)).ToActionResult(); + + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) => + (await mediator.Send(new GetRequestTypeQuery(id), ct)).ToActionResult(); + + [HttpPost] + public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => + (await mediator.Send(new CreateRequestTypeCommand(body.Name, body.PageUrl), ct)).ToActionResult(); + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => + (await mediator.Send(new UpdateRequestTypeCommand(id, body.Name, body.PageUrl), ct)).ToActionResult(); + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) => + (await mediator.Send(new DeleteRequestTypeCommand(id), ct)).ToActionResult(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs new file mode 100644 index 0000000..5c2b198 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +[ApiController] +[Route("api/template-routes/{routeId:guid}/steps")] +public sealed class TemplateRouteStepsController(ISender mediator) : ControllerBase +{ + [HttpGet] + public async Task List(Guid routeId, CancellationToken ct) => + (await mediator.Send(new ListTemplateRouteStepsQuery(routeId), ct)).ToActionResult(); + + [HttpGet("{id:guid}")] + public async Task Get(Guid routeId, Guid id, CancellationToken ct) => + (await mediator.Send(new GetTemplateRouteStepQuery(routeId, id), ct)).ToActionResult(); + + [HttpPost] + public async Task Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => + (await mediator.Send(new CreateTemplateRouteStepCommand(routeId, body), ct)).ToActionResult(); + + [HttpPut("{id:guid}")] + public async Task Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => + (await mediator.Send(new UpdateTemplateRouteStepCommand(routeId, id, body), ct)).ToActionResult(); + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid routeId, Guid id, CancellationToken ct) => + (await mediator.Send(new DeleteTemplateRouteStepCommand(routeId, id), ct)).ToActionResult(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs new file mode 100644 index 0000000..9d26b2e --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +[ApiController] +[Route("api/template-routes")] +public sealed class TemplateRoutesController(ISender mediator) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => + (await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult(); + + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) => + (await mediator.Send(new GetTemplateRouteQuery(id), ct)).ToActionResult(); + + [HttpPost] + public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) => + (await mediator.Send(new CreateTemplateRouteCommand(body), ct)).ToActionResult(); + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => + (await mediator.Send(new UpdateTemplateRouteCommand(id, body), ct)).ToActionResult(); + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) => + (await mediator.Send(new DeleteTemplateRouteCommand(id), ct)).ToActionResult(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 5d1223b..e9793c2 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Extensions.Hosting.AsyncInitialization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +12,8 @@ builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.AddOpenApi(); builder.Services.AddHealthChecks(); +builder.Services.AddControllers() + .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); @@ -18,6 +21,7 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.MapHealthChecks("/health"); +app.MapControllers(); await app.InitAndRunAsync(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs new file mode 100644 index 0000000..424fdef --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs @@ -0,0 +1,12 @@ +using FluentResults; + +namespace Tnsu.ApprovalRoute.Application.Common.Errors; + +public sealed class NotFoundError(string entity, Guid id) + : Error($"{entity} с id {id} не найден."); + +public sealed class ValidationError(string message) : Error(message); + +public sealed class ConflictError(string message) : Error(message); + +public sealed class Warning(string message) : Success(message); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs new file mode 100644 index 0000000..a2449cb --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs @@ -0,0 +1,75 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed record CreateRequestTypeCommand(string Name, string PageUrl) : IRequest>; + +public sealed record UpdateRequestTypeCommand(Guid Id, string Name, string PageUrl) : IRequest>; + +public sealed record DeleteRequestTypeCommand(Guid Id) : IRequest; + +public sealed class CreateRequestTypeHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateRequestTypeCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); + if (string.IsNullOrWhiteSpace(request.PageUrl)) + return Result.Fail(new ValidationError("Url страницы обязателен.")); + + var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; + db.RequestTypes.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(RequestTypeMapper.ToDto(entity)); + } +} + +public sealed class UpdateRequestTypeHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateRequestTypeCommand request, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", request.Id)); + + if (string.IsNullOrWhiteSpace(request.Name)) + return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); + if (string.IsNullOrWhiteSpace(request.PageUrl)) + return Result.Fail(new ValidationError("Url страницы обязателен.")); + + entity.Name = request.Name; + entity.PageUrl = request.PageUrl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(RequestTypeMapper.ToDto(entity)); + } +} + +public sealed class DeleteRequestTypeHandler(ApprovalRouteDbContext db) + : IRequestHandler +{ + public async Task Handle(DeleteRequestTypeCommand request, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", request.Id)); + + db.RequestTypes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } +} + +internal static class RequestTypeMapper +{ + public static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs new file mode 100644 index 0000000..fe74c5b --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -0,0 +1,7 @@ +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed record RequestTypeDto(Guid Id, string Name, string PageUrl); + +public sealed record CreateRequestTypeRequest(string Name, string PageUrl); + +public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs new file mode 100644 index 0000000..93b75cd --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs @@ -0,0 +1,39 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed record GetRequestTypeQuery(Guid Id) : IRequest>; + +public sealed record ListRequestTypesQuery : IRequest>>; + +public sealed class GetRequestTypeHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetRequestTypeQuery request, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Тип заявки", request.Id)) + : Result.Ok(RequestTypeMapper.ToDto(entity)); + } +} + +public sealed class ListRequestTypesHandler(ApprovalRouteDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle(ListRequestTypesQuery request, CancellationToken cancellationToken) + { + var items = await db.RequestTypes.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs new file mode 100644 index 0000000..fa858a8 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs @@ -0,0 +1,134 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed record CreateTemplateRouteStepCommand(Guid RouteId, CreateTemplateRouteStepRequest Request) + : IRequest>; + +public sealed record UpdateTemplateRouteStepCommand(Guid RouteId, Guid Id, UpdateTemplateRouteStepRequest Request) + : IRequest>; + +public sealed record DeleteTemplateRouteStepCommand(Guid RouteId, Guid Id) : IRequest; + +public sealed class CreateTemplateRouteStepHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateTemplateRouteStepCommand command, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", command.RouteId)); + + var r = command.Request; + var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); + if (fieldsError is not null) + return Result.Fail(fieldsError); + + var entity = new TemplateRouteStep + { + RouteId = route.Id, + OrderNo = r.OrderNo, + ExecutorEmail = r.ExecutorEmail, + Position = r.Position, + ProjectRole = r.ProjectRole, + HeadType = r.HeadType, + Status = r.Status, + AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers + }; + db.TemplateRouteSteps.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity)); + return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken); + } +} + +public sealed class UpdateTemplateRouteStepHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateTemplateRouteStepCommand command, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", command.RouteId)); + + var entity = await db.TemplateRouteSteps + .FirstOrDefaultAsync(x => x.Id == command.Id && x.RouteId == command.RouteId, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Шаг маршрута", command.Id)); + + var r = command.Request; + var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); + if (fieldsError is not null) + return Result.Fail(fieldsError); + + entity.OrderNo = r.OrderNo; + entity.ExecutorEmail = r.ExecutorEmail; + entity.Position = r.Position; + entity.ProjectRole = r.ProjectRole; + entity.HeadType = r.HeadType; + entity.Status = r.Status; + entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; + await db.SaveChangesAsync(cancellationToken); + + var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity)); + return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken); + } +} + +public sealed class DeleteTemplateRouteStepHandler(ApprovalRouteDbContext db) + : IRequestHandler +{ + public async Task Handle(DeleteTemplateRouteStepCommand request, CancellationToken cancellationToken) + { + var entity = await db.TemplateRouteSteps + .FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Шаг маршрута", request.Id)); + + db.TemplateRouteSteps.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } +} + +internal static class TemplateRouteStepValidation +{ + public static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject) + { + if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && projectRole == ProjectRole.None) + return new ValidationError("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + + if (!routeByProject && projectRole != ProjectRole.None) + return new ValidationError("Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."); + + return null; + } + + public static async Task> AppendMissingProjectRoleWarningAsync( + ApprovalRouteDbContext db, TemplateRoute route, Result result, CancellationToken cancellationToken) + { + if (!route.ByProject) + return result; + + var hasProjectRoleStep = await db.TemplateRouteSteps + .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken); + + return hasProjectRoleStep + ? result + : result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте.")); + } +} + +internal static class TemplateRouteStepMapper +{ + public static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( + e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs new file mode 100644 index 0000000..0a5f852 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -0,0 +1,32 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed record TemplateRouteStepDto( + Guid Id, + int OrderNo, + string ExecutorEmail, + string Position, + ProjectRole ProjectRole, + HeadType HeadType, + RouteStepStatus Status, + Guid RouteId, + bool AllowReassignToOtherApprovers); + +public sealed record CreateTemplateRouteStepRequest( + int OrderNo, + string ExecutorEmail, + string Position, + ProjectRole ProjectRole, + HeadType HeadType, + RouteStepStatus Status, + bool AllowReassignToOtherApprovers); + +public sealed record UpdateTemplateRouteStepRequest( + int OrderNo, + string ExecutorEmail, + string Position, + ProjectRole ProjectRole, + HeadType HeadType, + RouteStepStatus Status, + bool AllowReassignToOtherApprovers); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs new file mode 100644 index 0000000..2ddce86 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs @@ -0,0 +1,41 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed record GetTemplateRouteStepQuery(Guid RouteId, Guid Id) : IRequest>; + +public sealed record ListTemplateRouteStepsQuery(Guid RouteId) : IRequest>>; + +public sealed class GetTemplateRouteStepHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetTemplateRouteStepQuery request, CancellationToken cancellationToken) + { + var entity = await db.TemplateRouteSteps.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Шаг маршрута", request.Id)) + : Result.Ok(TemplateRouteStepMapper.ToDto(entity)); + } +} + +public sealed class ListTemplateRouteStepsHandler(ApprovalRouteDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle(ListTemplateRouteStepsQuery request, CancellationToken cancellationToken) + { + var items = await db.TemplateRouteSteps.AsNoTracking() + .Where(x => x.RouteId == request.RouteId) + .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)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs new file mode 100644 index 0000000..19c54a0 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs @@ -0,0 +1,122 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record CreateTemplateRouteCommand(CreateTemplateRouteRequest Request) : IRequest>; + +public sealed record UpdateTemplateRouteCommand(Guid Id, UpdateTemplateRouteRequest Request) : IRequest>; + +public sealed record DeleteTemplateRouteCommand(Guid Id) : IRequest; + +public sealed class CreateTemplateRouteHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateTemplateRouteCommand command, CancellationToken cancellationToken) + { + var r = command.Request; + + if (r.CompanyId == Guid.Empty) + return Result.Fail(new ValidationError("Компания обязательна.")); + + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); + + var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync( + db, r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + var entity = new TemplateRoute + { + RouteType = r.RouteType, + RequestTypeId = r.RequestTypeId, + DepartmentId = r.DepartmentId, + CompanyId = r.CompanyId, + ByProject = r.ByProject, + RequiresControl = r.RequiresControl + }; + db.TemplateRoutes.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(TemplateRouteMapper.ToDto(entity)); + } +} + +public sealed class UpdateTemplateRouteHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(UpdateTemplateRouteCommand command, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", command.Id)); + + var r = command.Request; + + if (r.CompanyId == Guid.Empty) + return Result.Fail(new ValidationError("Компания обязательна.")); + + var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync( + db, entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + entity.RouteType = r.RouteType; + entity.DepartmentId = r.DepartmentId; + entity.CompanyId = r.CompanyId; + entity.ByProject = r.ByProject; + entity.RequiresControl = r.RequiresControl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(TemplateRouteMapper.ToDto(entity)); + } +} + +public sealed class DeleteTemplateRouteHandler(ApprovalRouteDbContext db) + : IRequestHandler +{ + public async Task Handle(DeleteTemplateRouteCommand request, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", request.Id)); + + db.TemplateRoutes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } +} + +internal static class TemplateRouteUniqueness +{ + public static Task HasDuplicateAsync( + ApprovalRouteDbContext db, + Guid requestTypeId, + Guid? departmentId, + Guid companyId, + bool byProject, + Guid? excludeId, + CancellationToken cancellationToken) => + db.TemplateRoutes.AnyAsync(x => + x.Id != excludeId && + x.RequestTypeId == requestTypeId && + x.DepartmentId == departmentId && + x.CompanyId == companyId && + x.ByProject == byProject, + cancellationToken); +} + +internal static class TemplateRouteMapper +{ + public static TemplateRouteDto ToDto(TemplateRoute e) => new( + e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs new file mode 100644 index 0000000..7f7a518 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -0,0 +1,27 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record TemplateRouteDto( + Guid Id, + RouteType RouteType, + Guid RequestTypeId, + Guid? DepartmentId, + Guid CompanyId, + bool ByProject, + bool RequiresControl); + +public sealed record CreateTemplateRouteRequest( + RouteType RouteType, + Guid RequestTypeId, + Guid? DepartmentId, + Guid CompanyId, + bool ByProject, + bool RequiresControl); + +public sealed record UpdateTemplateRouteRequest( + RouteType RouteType, + Guid? DepartmentId, + Guid CompanyId, + bool ByProject, + bool RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs new file mode 100644 index 0000000..54e417a --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs @@ -0,0 +1,43 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record GetTemplateRouteQuery(Guid Id) : IRequest>; + +public sealed record ListTemplateRoutesQuery(Guid? RequestTypeId) : IRequest>>; + +public sealed class GetTemplateRouteHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetTemplateRouteQuery request, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Маршрут", request.Id)) + : Result.Ok(TemplateRouteMapper.ToDto(entity)); + } +} + +public sealed class ListTemplateRoutesHandler(ApprovalRouteDbContext db) + : IRequestHandler>> +{ + public async Task>> Handle(ListTemplateRoutesQuery request, CancellationToken cancellationToken) + { + var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); + if (request.RequestTypeId is { } requestTypeId) + query = query.Where(x => x.RequestTypeId == requestTypeId); + + var items = await query + .Select(x => new TemplateRouteDto( + x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index 60bfbd4..409b6fe 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -1,8 +1,11 @@ + + + diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index 79b63af..b5db8c5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -1,6 +1,5 @@ - -- 2.34.1 From f2a448d985d2f619f919d984f620da8e4ea9fb5c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 14:14:44 +0500 Subject: [PATCH 04/38] =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AuthSchemes.cs | 6 +++ .../Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs | 41 +++++++++++++++++++ .../Auth/CurrentUserAccessor.cs | 11 +++++ .../Auth/DevAuthHandler.cs | 27 ++++++++++++ .../Controllers/RequestTypesController.cs | 2 + .../TemplateRouteStepsController.cs | 2 + .../Controllers/TemplateRoutesController.cs | 2 + backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 9 ++++ .../Tnsu.ApprovalRoute.Api/appsettings.json | 7 ++++ .../Common/Interfaces/ICurrentUser.cs | 6 +++ 10 files changed, 113 insertions(+) create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs new file mode 100644 index 0000000..331bf3d --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs @@ -0,0 +1,6 @@ +namespace Tnsu.ApprovalRoute.Api.Auth; + +public static class AuthSchemes +{ + public const string Dev = "Dev"; +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs new file mode 100644 index 0000000..e72b17c --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace Tnsu.ApprovalRoute.Api.Auth; + +public static class AuthSetup +{ + public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration) + { + var tenantId = configuration["Entra:TenantId"]; + var audience = configuration["Entra:Audience"]; + + if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience)) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0"; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuers = + [ + $"https://login.microsoftonline.com/{tenantId}/v2.0", + $"https://sts.windows.net/{tenantId}/" + ], + ValidAudiences = [audience, $"api://{audience}"] + }; + }); + } + else + { + services.AddAuthentication(AuthSchemes.Dev) + .AddScheme(AuthSchemes.Dev, _ => { }); + } + + services.AddAuthorization(); + + return services; + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs new file mode 100644 index 0000000..42e31cd --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; + +namespace Tnsu.ApprovalRoute.Api.Auth; + +public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser +{ + public string? Email => + httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email) + ?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username"); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs new file mode 100644 index 0000000..ed829eb --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace Tnsu.ApprovalRoute.Api.Auth; + +// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID). +public sealed class DevAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault() + ?? configuration["Dev:DefaultUserEmail"] + ?? "dev@tnsu.kz"; + + var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index 5dac9c2..fda5461 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Application.RequestTypes; @@ -6,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.RequestTypes; namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] +[Authorize] [Route("api/request-types")] public sealed class RequestTypesController(ISender mediator) : ControllerBase { diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs index 5c2b198..f069f64 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; @@ -6,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] +[Authorize] [Route("api/template-routes/{routeId:guid}/steps")] public sealed class TemplateRouteStepsController(ISender mediator) : ControllerBase { diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 9d26b2e..6cac78c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -1,4 +1,5 @@ using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Application.TemplateRoutes; @@ -6,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.TemplateRoutes; namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] +[Authorize] [Route("api/template-routes")] public sealed class TemplateRoutesController(ISender mediator) : ControllerBase { diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index e9793c2..01a6c2d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -3,13 +3,18 @@ using Extensions.Hosting.AsyncInitialization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; var builder = WebApplication.CreateBuilder(args); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); +builder.Services.AddApprovalRouteAuth(builder.Configuration); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); builder.Services.AddOpenApi(); builder.Services.AddHealthChecks(); builder.Services.AddControllers() @@ -21,6 +26,10 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.MapHealthChecks("/health"); + +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); await app.InitAndRunAsync(); diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index 7cd39c3..9631835 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -7,5 +7,12 @@ }, "ConnectionStrings": { "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" + }, + "Entra": { + "TenantId": "", + "Audience": "" + }, + "Dev": { + "DefaultUserEmail": "dev@tnsu.kz" } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs new file mode 100644 index 0000000..673c68c --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Interfaces/ICurrentUser.cs @@ -0,0 +1,6 @@ +namespace Tnsu.ApprovalRoute.Application.Common.Interfaces; + +public interface ICurrentUser +{ + string? Email { get; } +} -- 2.34.1 From 409093c44415e90d0eb848f9c08b534d116329a0 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 14:28:48 +0500 Subject: [PATCH 05/38] =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20tansu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 + .../Tnsu.ApprovalRoute.Api/appsettings.json | 7 +++ ...frastructureServiceCollectionExtensions.cs | 10 +++++ .../Tnsu.ApprovalRoute.Infrastructure.csproj | 1 + .../TnsuPortal/HttpTnsuPortalClient.cs | 20 +++++++++ .../TnsuPortal/TnsuPortalDtos.cs | 7 +++ .../TnsuPortal/TnsuPortalOptions.cs | 10 +++++ .../TnsuPortal/TnsuPortalTokenProvider.cs | 43 +++++++++++++++++++ 8 files changed, 99 insertions(+) create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 30eec15..6673224 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index 9631835..3205e20 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -14,5 +14,12 @@ }, "Dev": { "DefaultUserEmail": "dev@tnsu.kz" + }, + "TnsuPortal": { + "BaseUrl": "https://api.tnsu.kz", + "TenantId": "", + "ClientId": "", + "ClientSecret": "", + "Scope": "" } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index a006dd4..bf2a02e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -2,7 +2,9 @@ using Extensions.Hosting.AsyncInitialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure; @@ -16,6 +18,14 @@ public static class InfrastructureServiceCollectionExtensions .UseSnakeCaseNamingConvention()); services.AddAsyncInitializer(); + services.Configure(configuration.GetSection("TnsuPortal")); + services.AddHttpClient(); + services.AddHttpClient((sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + }); + return services; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index b5db8c5..5ea6939 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -11,6 +11,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs new file mode 100644 index 0000000..f2f1488 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -0,0 +1,20 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +// Клиент api.tnsu.kz (api-tnsu-portal). GetEmployeeByEmailAsync и GetProjectExecutorsAsync — заглушки: +// на момент написания у api-tnsu-portal нет эндпоинта GET /Employee/ByEmail (есть только ByIIN/Manager), +// а форма ответа GET /Project/{oid} в его openapi-спеке не типизирована. Заменить на реальные вызовы, +// когда эндпоинты будут готовы — токен уже прокидывается через TnsuPortalTokenProvider. +public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) +{ + public Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) + { + return Task.FromResult(new EmployeeDirectoryInfo( + DepartmentId: Guid.Parse("00000000-0000-0000-0000-000000000001"), + CompanyId: Guid.Parse("00000000-0000-0000-0000-000000000002"))); + } + + public Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs new file mode 100644 index 0000000..befde6a --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -0,0 +1,7 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public sealed record EmployeeDirectoryInfo(Guid DepartmentId, Guid CompanyId); + +public sealed record ProjectExecutorInfo(string Email, string Position, ProjectRole ProjectRole); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs new file mode 100644 index 0000000..47469a7 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs @@ -0,0 +1,10 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public sealed class TnsuPortalOptions +{ + public string BaseUrl { get; set; } = "https://api.tnsu.kz"; + public string TenantId { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs new file mode 100644 index 0000000..6f7dfd4 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs @@ -0,0 +1,43 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя. +public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions options) +{ + private string? _cachedToken; + private DateTimeOffset _expiresAt = DateTimeOffset.MinValue; + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt) + return _cachedToken; + + var opts = options.Value; + var response = await httpClient.PostAsync( + $"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = opts.ClientId, + ["client_secret"] = opts.ClientSecret, + ["scope"] = opts.Scope + }), + cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("Не удалось получить токен для api.tnsu.kz."); + + _cachedToken = payload.AccessToken; + _expiresAt = DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn - 60); + + return _cachedToken; + } + + private sealed record TokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("expires_in")] int ExpiresIn); +} -- 2.34.1 From f29fee8339baeff8abde20893a8e1cbb6ccede9b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 14:38:16 +0500 Subject: [PATCH 06/38] =?UTF-8?q?=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/TemplateRoutesController.cs | 10 ++ .../TemplateRoutes/ResolveTemplateRouteDto.cs | 19 ++++ .../ResolveTemplateRouteQuery.cs | 95 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 6cac78c..7946471 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Api.Controllers; @@ -11,6 +12,15 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [Route("api/template-routes")] public sealed class TemplateRoutesController(ISender mediator) : ControllerBase { + [HttpGet("resolve")] + public async Task Resolve( + [FromQuery] Guid requestTypeId, + [FromQuery] string? userEmail, + [FromQuery] Guid? projectId, + [FromQuery] RouteStepStatus status, + CancellationToken ct) => + (await mediator.Send(new ResolveTemplateRouteQuery(requestTypeId, userEmail, projectId, status), ct)).ToActionResult(); + [HttpGet] public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => (await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs new file mode 100644 index 0000000..7f828dd --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -0,0 +1,19 @@ +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record ResolvedRouteStepDto( + Guid Id, + int OrderNo, + string ExecutorEmail, + string Position, + ProjectRole ProjectRole, + HeadType HeadType, + RouteStepStatus Status, + bool AllowReassignToOtherApprovers); + +public sealed record ResolveTemplateRouteResultDto( + Guid RouteId, + RouteType RouteType, + bool RequiresControl, + IReadOnlyList Steps); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs new file mode 100644 index 0000000..5e83386 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs @@ -0,0 +1,95 @@ +using FluentResults; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed record ResolveTemplateRouteQuery( + Guid RequestTypeId, + string? UserEmail, + Guid? ProjectId, + RouteStepStatus Status) : IRequest>; + +public sealed class ResolveTemplateRouteHandler( + ApprovalRouteDbContext db, + HttpTnsuPortalClient portalClient, + ICurrentUser currentUser) + : IRequestHandler> +{ + public async Task> Handle(ResolveTemplateRouteQuery request, CancellationToken cancellationToken) + { + if (request.Status == RouteStepStatus.None) + return Result.Fail( + new ValidationError("Статус обязателен: Approval или Execution.")); + + var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail; + if (string.IsNullOrWhiteSpace(email)) + return Result.Fail( + new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); + + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); + + var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); + var byProject = request.ProjectId is not null; + + var route = await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == request.RequestTypeId && + x.CompanyId == employee.CompanyId && + x.ByProject == byProject && + x.DepartmentId == employee.DepartmentId) + .FirstOrDefaultAsync(cancellationToken) + ?? await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == request.RequestTypeId && + x.CompanyId == employee.CompanyId && + x.ByProject == byProject && + x.DepartmentId == null) + .FirstOrDefaultAsync(cancellationToken); + + if (route is null) + return Result.Fail(new NotFoundError( + "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", + request.RequestTypeId)); + + IReadOnlyList projectExecutors = []; + if (request.ProjectId is { } projectId) + projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); + + var steps = route.Steps + .Where(s => s.Status == request.Status) + .OrderBy(s => s.OrderNo) + .Select(s => ResolveStep(s, projectExecutors)) + .ToList(); + + return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps)); + } + + private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) + { + // Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz), + // а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту. + if (step.ProjectRole != ProjectRole.None) + { + var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); + if (projectExecutor is not null) + return new ResolvedRouteStepDto( + step.Id, step.OrderNo, projectExecutor.Email, projectExecutor.Position, + step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); + } + + return new ResolvedRouteStepDto( + step.Id, step.OrderNo, step.ExecutorEmail, step.Position, + step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); + } +} -- 2.34.1 From 52d3325f26081f957cfffe470c08824e59444b02 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 14:47:23 +0500 Subject: [PATCH 07/38] =?UTF-8?q?=D0=B2=D1=80=D0=BE=D0=B4=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA=20=D1=81=D0=B2=D0=B0=D0=B3=D0=B5=D1=80=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 + backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 38 ++++++++++++++++++- .../Tnsu.ApprovalRoute.Api.csproj | 1 + 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 6673224..5696310 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -14,5 +14,6 @@ + \ No newline at end of file diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 01a6c2d..76e4a2d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -3,6 +3,7 @@ using Extensions.Hosting.AsyncInitialization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application.Common.Interfaces; @@ -15,7 +16,33 @@ builder.Services.AddApprovalRouteApplication(); builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); -builder.Services.AddOpenApi(); +builder.Services.AddOpenApi(options => +{ + options.AddDocumentTransformer((document, _, _) => + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Введите JWT токен" + }; + return Task.CompletedTask; + }); + options.AddOperationTransformer((operation, context, _) => + { + operation.Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference("Bearer", context.Document)] = [] + } + ]; + return Task.CompletedTask; + }); +}); builder.Services.AddHealthChecks(); builder.Services.AddControllers() .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); @@ -23,7 +50,16 @@ builder.Services.AddControllers() var app = builder.Build(); if (app.Environment.IsDevelopment()) +{ app.MapOpenApi(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/openapi/v1.json", "Tnsu Approval Route v1"); + c.RoutePrefix = string.Empty; + c.ConfigObject.AdditionalItems["persistAuthorization"] = true; + c.EnableTryItOutByDefault(); + }); +} app.MapHealthChecks("/health"); diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index 325ff39..72b635e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -12,5 +12,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + -- 2.34.1 From 436c8ce7d4b4b5c89ece62e2ea172dc26d686722 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 14:55:04 +0500 Subject: [PATCH 08/38] =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=87=20=D1=81=D0=B5?= =?UTF-8?q?=D1=82=D1=82=D0=B8=D0=BD=D0=B3=D1=81=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Properties/launchSettings.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json b/backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json new file mode 100644 index 0000000..543069b --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} -- 2.34.1 From 9b27f328a7d757d564ecb46a365cffc157a19ae7 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 15:26:06 +0500 Subject: [PATCH 09/38] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=20=D0=B2=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 47 +++++++++++++++++++ backend/Directory.Packages.props | 4 ++ backend/Tnsu.ApprovalRoute.sln | 17 +++++++ ...Tnsu.ApprovalRoute.IntegrationTests.csproj | 22 +++++++++ 4 files changed, 90 insertions(+) create mode 100644 .gitea/workflows/test.yml create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..6f22764 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,47 @@ +name: Tests + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: tnsu_approval_route + POSTGRES_USER: tnsu_approval_route + POSTGRES_PASSWORD: tnsu_approval_route + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U tnsu_approval_route -d tnsu_approval_route" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore backend/Tnsu.ApprovalRoute.sln + + - name: Build + run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release + + - name: Test + env: + TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" + run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 5696310..1d529ff 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -15,5 +15,9 @@ + + + + \ No newline at end of file diff --git a/backend/Tnsu.ApprovalRoute.sln b/backend/Tnsu.ApprovalRoute.sln index e505b17..ee88a58 100644 --- a/backend/Tnsu.ApprovalRoute.sln +++ b/backend/Tnsu.ApprovalRoute.sln @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.IntegrationTests", "tests\Tnsu.ApprovalRoute.IntegrationTests\Tnsu.ApprovalRoute.IntegrationTests.csproj", "{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +75,18 @@ Global {4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU {4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU {4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.Build.0 = Debug|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.Build.0 = Debug|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.Build.0 = Release|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.ActiveCfg = Release|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.Build.0 = Release|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.ActiveCfg = Release|Any CPU + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -80,5 +96,6 @@ Global {2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj new file mode 100644 index 0000000..efe1f0d --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj @@ -0,0 +1,22 @@ + + + + false + + + + + + + + + + + + + + + + + + -- 2.34.1 From a0afac6e07c194534440dae1032c0403d98aae5f Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 15:30:04 +0500 Subject: [PATCH 10/38] =?UTF-8?q?=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B8=D0=B9=20=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestTypePostgresTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs new file mode 100644 index 0000000..43f4131 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.IntegrationTests; + +public class RequestTypePostgresTests +{ + private static string ConnectionString => + Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") + ?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"; + + [Fact] + public async Task CanInsertAndReadBack_RealPostgres() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseSnakeCaseNamingConvention() + .Options; + + await using var db = new ApprovalRouteDbContext(options); + await db.Database.MigrateAsync(); + + // Транзакция без коммита — тест не оставляет данных в базе после себя. + await using var transaction = await db.Database.BeginTransactionAsync(); + + var entity = new RequestType { Name = "Integration test type", PageUrl = "/integration-test" }; + db.RequestTypes.Add(entity); + await db.SaveChangesAsync(); + + db.ChangeTracker.Clear(); + var loaded = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == entity.Id); + + Assert.NotNull(loaded); + Assert.Equal("Integration test type", loaded.Name); + Assert.Equal("/integration-test", loaded.PageUrl); + + await transaction.RollbackAsync(); + } +} -- 2.34.1 From 3c3e7caf813de1602344e16950b2c37705d7a198 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 15:36:52 +0500 Subject: [PATCH 11/38] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml b/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml index 7b08163..4b0e0c4 100644 --- a/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml +++ b/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml @@ -1,7 +1,9 @@ - + + ../.gitea/workflows + -- 2.34.1 From 7f378ad632e08d1b0edff953372345280ba803f2 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 15:39:20 +0500 Subject: [PATCH 12/38] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B1=D1=83=D0=B5?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 6f22764..b39a003 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -43,5 +43,7 @@ jobs: - name: Test env: - TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" + # act_runner запускает джобу в отдельном контейнере на одной docker-сети с сервисами — + # postgres здесь резолвится по имени сервиса, а не через localhost/127.0.0.1 (это не VM, как в GitHub-hosted runners). + TEST_POSTGRES_CONNECTION: "Host=postgres;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release -- 2.34.1 From 369354f0c90848446d8480fd2ddcef4a06c387ea Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 15:46:36 +0500 Subject: [PATCH 13/38] Wait for Postgres --- .gitea/workflows/test.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b39a003..3780237 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -30,17 +30,25 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - name: Restore run: dotnet restore backend/Tnsu.ApprovalRoute.sln - name: Build run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release + - name: Wait for Postgres + run: | + for i in $(seq 1 30); do + if (echo > /dev/tcp/postgres/5432) 2>/dev/null; then + echo "Postgres is reachable" + exit 0 + fi + echo "Waiting for postgres... ($i/30)" + sleep 2 + done + echo "Postgres did not become reachable in time" >&2 + exit 1 + - name: Test env: # act_runner запускает джобу в отдельном контейнере на одной docker-сети с сервисами — -- 2.34.1 From c3b479870348af3fa35d5d13295934991902b58e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 15:49:26 +0500 Subject: [PATCH 14/38] =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=20dotnet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 3780237..fd84cad 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -30,6 +30,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Restore run: dotnet restore backend/Tnsu.ApprovalRoute.sln -- 2.34.1 From b496fcdcddf1437a1f21d8951e07dd757e368035 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 16:00:40 +0500 Subject: [PATCH 15/38] =?UTF-8?q?=D0=BF=D0=BE=D0=BC=D0=B5=D0=BD=D1=8F?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index fd84cad..b495251 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -35,12 +35,6 @@ jobs: with: dotnet-version: '10.0.x' - - name: Restore - run: dotnet restore backend/Tnsu.ApprovalRoute.sln - - - name: Build - run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release - - name: Wait for Postgres run: | for i in $(seq 1 30); do @@ -54,6 +48,12 @@ jobs: echo "Postgres did not become reachable in time" >&2 exit 1 + - name: Restore + run: dotnet restore backend/Tnsu.ApprovalRoute.sln + + - name: Build + run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release + - name: Test env: # act_runner запускает джобу в отдельном контейнере на одной docker-сети с сервисами — -- 2.34.1 From 9921c1f4b3b539f737a41391ce560cf573a78b54 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 16:06:11 +0500 Subject: [PATCH 16/38] =?UTF-8?q?=D0=B5=D1=89=D0=B5=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=8B=D1=82=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b495251..e27ddae 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -11,20 +11,6 @@ on: jobs: test: runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_DB: tnsu_approval_route - POSTGRES_USER: tnsu_approval_route - POSTGRES_PASSWORD: tnsu_approval_route - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U tnsu_approval_route -d tnsu_approval_route" - --health-interval 5s - --health-timeout 3s - --health-retries 10 steps: - name: Checkout @@ -34,18 +20,26 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - + + - name: Install PostgreSQL + run: | + sudo apt-get update + sudo apt-get install -y postgresql + sudo systemctl start postgresql + sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;" + sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;" + - name: Wait for Postgres run: | for i in $(seq 1 30); do - if (echo > /dev/tcp/postgres/5432) 2>/dev/null; then - echo "Postgres is reachable" + if pg_isready -h localhost -p 5432 -U tnsu_approval_route -d tnsu_approval_route; then + echo "Postgres is ready" exit 0 fi echo "Waiting for postgres... ($i/30)" sleep 2 done - echo "Postgres did not become reachable in time" >&2 + echo "Postgres did not become ready in time" >&2 exit 1 - name: Restore @@ -56,7 +50,5 @@ jobs: - name: Test env: - # act_runner запускает джобу в отдельном контейнере на одной docker-сети с сервисами — - # postgres здесь резолвится по имени сервиса, а не через localhost/127.0.0.1 (это не VM, как в GitHub-hosted runners). - TEST_POSTGRES_CONNECTION: "Host=postgres;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" + TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release -- 2.34.1 From c36902a80f2637782510deda0639884192696a30 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 16:09:02 +0500 Subject: [PATCH 17/38] =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20systemd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index e27ddae..1f89739 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -25,7 +25,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y postgresql - sudo systemctl start postgresql + sudo pg_ctlcluster 16 main start sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;" sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;" -- 2.34.1 From 0ddb9f0ba67706f986bfe954e6c8e06b8a015885 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 16:31:52 +0500 Subject: [PATCH 18/38] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20postgres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/test.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1f89739..fa3ff8b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -29,19 +29,6 @@ jobs: sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;" sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;" - - name: Wait for Postgres - run: | - for i in $(seq 1 30); do - if pg_isready -h localhost -p 5432 -U tnsu_approval_route -d tnsu_approval_route; then - echo "Postgres is ready" - exit 0 - fi - echo "Waiting for postgres... ($i/30)" - sleep 2 - done - echo "Postgres did not become ready in time" >&2 - exit 1 - - name: Restore run: dotnet restore backend/Tnsu.ApprovalRoute.sln -- 2.34.1 From 7cce18e67ba5ce34df7518e0d322c0f699e4e6ca Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 16:45:18 +0500 Subject: [PATCH 19/38] =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BE?= =?UTF-8?q?=D1=82=20mediatr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 - .../Controllers/RequestTypesController.cs | 13 +- .../TemplateRouteStepsController.cs | 13 +- .../Controllers/TemplateRoutesController.cs | 15 +- .../Tnsu.ApprovalRoute.Api.csproj | 1 - .../ApplicationServiceCollectionExtensions.cs | 10 +- .../RequestTypes/RequestTypeCommands.cs | 75 ------- .../RequestTypes/RequestTypeQueries.cs | 39 ---- .../RequestTypes/RequestTypeService.cs | 75 +++++++ .../TemplateRouteStepQueries.cs | 41 ---- ...ommands.cs => TemplateRouteStepService.cs} | 97 +++++---- .../ResolveTemplateRouteQuery.cs | 95 --------- .../TemplateRoutes/TemplateRouteCommands.cs | 122 ----------- .../TemplateRoutes/TemplateRouteQueries.cs | 43 ---- .../TemplateRoutes/TemplateRouteService.cs | 189 ++++++++++++++++++ .../Tnsu.ApprovalRoute.Application.csproj | 1 - 16 files changed, 336 insertions(+), 494 deletions(-) delete mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs rename backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/{TemplateRouteStepCommands.cs => TemplateRouteStepService.cs} (50%) delete mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 1d529ff..55b277f 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -13,7 +13,6 @@ - diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index fda5461..9c0644a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -1,4 +1,3 @@ -using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; @@ -9,25 +8,25 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] [Route("api/request-types")] -public sealed class RequestTypesController(ISender mediator) : ControllerBase +public sealed class RequestTypesController(RequestTypeService service) : ControllerBase { [HttpGet] public async Task List(CancellationToken ct) => - (await mediator.Send(new ListRequestTypesQuery(), ct)).ToActionResult(); + (await service.ListAsync(ct)).ToActionResult(); [HttpGet("{id:guid}")] public async Task Get(Guid id, CancellationToken ct) => - (await mediator.Send(new GetRequestTypeQuery(id), ct)).ToActionResult(); + (await service.GetAsync(id, ct)).ToActionResult(); [HttpPost] public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => - (await mediator.Send(new CreateRequestTypeCommand(body.Name, body.PageUrl), ct)).ToActionResult(); + (await service.CreateAsync(body.Name, body.PageUrl, ct)).ToActionResult(); [HttpPut("{id:guid}")] public async Task Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => - (await mediator.Send(new UpdateRequestTypeCommand(id, body.Name, body.PageUrl), ct)).ToActionResult(); + (await service.UpdateAsync(id, body.Name, body.PageUrl, ct)).ToActionResult(); [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) => - (await mediator.Send(new DeleteRequestTypeCommand(id), ct)).ToActionResult(); + (await service.DeleteAsync(id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs index f069f64..57d9e79 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -1,4 +1,3 @@ -using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; @@ -9,25 +8,25 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] [Route("api/template-routes/{routeId:guid}/steps")] -public sealed class TemplateRouteStepsController(ISender mediator) : ControllerBase +public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase { [HttpGet] public async Task List(Guid routeId, CancellationToken ct) => - (await mediator.Send(new ListTemplateRouteStepsQuery(routeId), ct)).ToActionResult(); + (await service.ListAsync(routeId, ct)).ToActionResult(); [HttpGet("{id:guid}")] public async Task Get(Guid routeId, Guid id, CancellationToken ct) => - (await mediator.Send(new GetTemplateRouteStepQuery(routeId, id), ct)).ToActionResult(); + (await service.GetAsync(routeId, id, ct)).ToActionResult(); [HttpPost] public async Task Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => - (await mediator.Send(new CreateTemplateRouteStepCommand(routeId, body), ct)).ToActionResult(); + (await service.CreateAsync(routeId, body, ct)).ToActionResult(); [HttpPut("{id:guid}")] public async Task Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => - (await mediator.Send(new UpdateTemplateRouteStepCommand(routeId, id, body), ct)).ToActionResult(); + (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] public async Task Delete(Guid routeId, Guid id, CancellationToken ct) => - (await mediator.Send(new DeleteTemplateRouteStepCommand(routeId, id), ct)).ToActionResult(); + (await service.DeleteAsync(routeId, id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 7946471..473034f 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -1,4 +1,3 @@ -using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; @@ -10,7 +9,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] [Route("api/template-routes")] -public sealed class TemplateRoutesController(ISender mediator) : ControllerBase +public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase { [HttpGet("resolve")] public async Task Resolve( @@ -19,25 +18,25 @@ public sealed class TemplateRoutesController(ISender mediator) : ControllerBase [FromQuery] Guid? projectId, [FromQuery] RouteStepStatus status, CancellationToken ct) => - (await mediator.Send(new ResolveTemplateRouteQuery(requestTypeId, userEmail, projectId, status), ct)).ToActionResult(); + (await service.ResolveAsync(requestTypeId, userEmail, projectId, status, ct)).ToActionResult(); [HttpGet] public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => - (await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult(); + (await service.ListAsync(requestTypeId, ct)).ToActionResult(); [HttpGet("{id:guid}")] public async Task Get(Guid id, CancellationToken ct) => - (await mediator.Send(new GetTemplateRouteQuery(id), ct)).ToActionResult(); + (await service.GetAsync(id, ct)).ToActionResult(); [HttpPost] public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) => - (await mediator.Send(new CreateTemplateRouteCommand(body), ct)).ToActionResult(); + (await service.CreateAsync(body, ct)).ToActionResult(); [HttpPut("{id:guid}")] public async Task Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => - (await mediator.Send(new UpdateTemplateRouteCommand(id, body), ct)).ToActionResult(); + (await service.UpdateAsync(id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) => - (await mediator.Send(new DeleteTemplateRouteCommand(id), ct)).ToActionResult(); + (await service.DeleteAsync(id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index 72b635e..a941843 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -7,7 +7,6 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 51291b7..63a6f1b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ -using System.Reflection; -using MediatR; using Microsoft.Extensions.DependencyInjection; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; namespace Tnsu.ApprovalRoute.Application; @@ -8,7 +9,10 @@ public static class ApplicationServiceCollectionExtensions { public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services) { - services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs deleted file mode 100644 index a2449cb..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs +++ /dev/null @@ -1,75 +0,0 @@ -using FluentResults; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Application.Common.Errors; -using Tnsu.ApprovalRoute.Domain.Entities; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; - -namespace Tnsu.ApprovalRoute.Application.RequestTypes; - -public sealed record CreateRequestTypeCommand(string Name, string PageUrl) : IRequest>; - -public sealed record UpdateRequestTypeCommand(Guid Id, string Name, string PageUrl) : IRequest>; - -public sealed record DeleteRequestTypeCommand(Guid Id) : IRequest; - -public sealed class CreateRequestTypeHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(CreateRequestTypeCommand request, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(request.Name)) - return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); - if (string.IsNullOrWhiteSpace(request.PageUrl)) - return Result.Fail(new ValidationError("Url страницы обязателен.")); - - var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; - db.RequestTypes.Add(entity); - await db.SaveChangesAsync(cancellationToken); - - return Result.Ok(RequestTypeMapper.ToDto(entity)); - } -} - -public sealed class UpdateRequestTypeHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(UpdateRequestTypeCommand request, CancellationToken cancellationToken) - { - var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - if (entity is null) - return Result.Fail(new NotFoundError("Тип заявки", request.Id)); - - if (string.IsNullOrWhiteSpace(request.Name)) - return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); - if (string.IsNullOrWhiteSpace(request.PageUrl)) - return Result.Fail(new ValidationError("Url страницы обязателен.")); - - entity.Name = request.Name; - entity.PageUrl = request.PageUrl; - await db.SaveChangesAsync(cancellationToken); - - return Result.Ok(RequestTypeMapper.ToDto(entity)); - } -} - -public sealed class DeleteRequestTypeHandler(ApprovalRouteDbContext db) - : IRequestHandler -{ - public async Task Handle(DeleteRequestTypeCommand request, CancellationToken cancellationToken) - { - var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - if (entity is null) - return Result.Fail(new NotFoundError("Тип заявки", request.Id)); - - db.RequestTypes.Remove(entity); - await db.SaveChangesAsync(cancellationToken); - - return Result.Ok(); - } -} - -internal static class RequestTypeMapper -{ - public static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); -} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs deleted file mode 100644 index 93b75cd..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FluentResults; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Application.Common.Errors; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; - -namespace Tnsu.ApprovalRoute.Application.RequestTypes; - -public sealed record GetRequestTypeQuery(Guid Id) : IRequest>; - -public sealed record ListRequestTypesQuery : IRequest>>; - -public sealed class GetRequestTypeHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(GetRequestTypeQuery request, CancellationToken cancellationToken) - { - var entity = await db.RequestTypes.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - - return entity is null - ? Result.Fail(new NotFoundError("Тип заявки", request.Id)) - : Result.Ok(RequestTypeMapper.ToDto(entity)); - } -} - -public sealed class ListRequestTypesHandler(ApprovalRouteDbContext db) - : IRequestHandler>> -{ - public async Task>> Handle(ListRequestTypesQuery request, CancellationToken cancellationToken) - { - var items = await db.RequestTypes.AsNoTracking() - .OrderBy(x => x.Name) - .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) - .ToListAsync(cancellationToken); - - return Result.Ok>(items); - } -} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs new file mode 100644 index 0000000..6be017c --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -0,0 +1,75 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed class RequestTypeService(ApprovalRouteDbContext db) +{ + public async Task>> ListAsync(CancellationToken cancellationToken) + { + var items = await db.RequestTypes.AsNoTracking() + .OrderBy(x => x.Name) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + public async Task> GetAsync(Guid id, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Тип заявки", id)) + : Result.Ok(ToDto(entity)); + } + + public async Task> CreateAsync(string name, string pageUrl, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(name)) + return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); + if (string.IsNullOrWhiteSpace(pageUrl)) + return Result.Fail(new ValidationError("Url страницы обязателен.")); + + var entity = new RequestType { Name = name, PageUrl = pageUrl }; + db.RequestTypes.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task> UpdateAsync(Guid id, string name, string pageUrl, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", id)); + + if (string.IsNullOrWhiteSpace(name)) + return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); + if (string.IsNullOrWhiteSpace(pageUrl)) + return Result.Fail(new ValidationError("Url страницы обязателен.")); + + entity.Name = name; + entity.PageUrl = pageUrl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", id)); + + db.RequestTypes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } + + private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs deleted file mode 100644 index 2ddce86..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs +++ /dev/null @@ -1,41 +0,0 @@ -using FluentResults; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Application.Common.Errors; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; - -namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; - -public sealed record GetTemplateRouteStepQuery(Guid RouteId, Guid Id) : IRequest>; - -public sealed record ListTemplateRouteStepsQuery(Guid RouteId) : IRequest>>; - -public sealed class GetTemplateRouteStepHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(GetTemplateRouteStepQuery request, CancellationToken cancellationToken) - { - var entity = await db.TemplateRouteSteps.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken); - - return entity is null - ? Result.Fail(new NotFoundError("Шаг маршрута", request.Id)) - : Result.Ok(TemplateRouteStepMapper.ToDto(entity)); - } -} - -public sealed class ListTemplateRouteStepsHandler(ApprovalRouteDbContext db) - : IRequestHandler>> -{ - public async Task>> Handle(ListTemplateRouteStepsQuery request, CancellationToken cancellationToken) - { - var items = await db.TemplateRouteSteps.AsNoTracking() - .Where(x => x.RouteId == request.RouteId) - .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)) - .ToListAsync(cancellationToken); - - return Result.Ok>(items); - } -} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs similarity index 50% rename from backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs rename to backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index fa858a8..ae8c55f 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -1,5 +1,4 @@ using FluentResults; -using MediatR; using Microsoft.EntityFrameworkCore; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Domain.Entities; @@ -8,25 +7,37 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; -public sealed record CreateTemplateRouteStepCommand(Guid RouteId, CreateTemplateRouteStepRequest Request) - : IRequest>; - -public sealed record UpdateTemplateRouteStepCommand(Guid RouteId, Guid Id, UpdateTemplateRouteStepRequest Request) - : IRequest>; - -public sealed record DeleteTemplateRouteStepCommand(Guid RouteId, Guid Id) : IRequest; - -public sealed class CreateTemplateRouteStepHandler(ApprovalRouteDbContext db) - : IRequestHandler> +public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) { - public async Task> Handle(CreateTemplateRouteStepCommand command, CancellationToken cancellationToken) + public async Task>> ListAsync(Guid routeId, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken); - if (route is null) - return Result.Fail(new NotFoundError("Маршрут", command.RouteId)); + var items = await db.TemplateRouteSteps.AsNoTracking() + .Where(x => x.RouteId == routeId) + .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)) + .ToListAsync(cancellationToken); - var r = command.Request; - var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); + return Result.Ok>(items); + } + + public async Task> GetAsync(Guid routeId, Guid id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRouteSteps.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Шаг маршрута", id)) + : Result.Ok(ToDto(entity)); + } + + public async Task> CreateAsync(Guid routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", routeId)); + + var fieldsError = ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); if (fieldsError is not null) return Result.Fail(fieldsError); @@ -44,27 +55,22 @@ public sealed class CreateTemplateRouteStepHandler(ApprovalRouteDbContext db) db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); - var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity)); - return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken); + var result = Result.Ok(ToDto(entity)); + return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken); } -} -public sealed class UpdateTemplateRouteStepHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(UpdateTemplateRouteStepCommand command, CancellationToken cancellationToken) + public async Task> UpdateAsync( + Guid routeId, Guid id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken); + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) - return Result.Fail(new NotFoundError("Маршрут", command.RouteId)); + return Result.Fail(new NotFoundError("Маршрут", routeId)); - var entity = await db.TemplateRouteSteps - .FirstOrDefaultAsync(x => x.Id == command.Id && x.RouteId == command.RouteId, cancellationToken); + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); if (entity is null) - return Result.Fail(new NotFoundError("Шаг маршрута", command.Id)); + return Result.Fail(new NotFoundError("Шаг маршрута", id)); - var r = command.Request; - var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); + var fieldsError = ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); if (fieldsError is not null) return Result.Fail(fieldsError); @@ -77,31 +83,23 @@ public sealed class UpdateTemplateRouteStepHandler(ApprovalRouteDbContext db) entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; await db.SaveChangesAsync(cancellationToken); - var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity)); - return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken); + var result = Result.Ok(ToDto(entity)); + return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken); } -} -public sealed class DeleteTemplateRouteStepHandler(ApprovalRouteDbContext db) - : IRequestHandler -{ - public async Task Handle(DeleteTemplateRouteStepCommand request, CancellationToken cancellationToken) + public async Task DeleteAsync(Guid routeId, Guid id, CancellationToken cancellationToken) { - var entity = await db.TemplateRouteSteps - .FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken); + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); if (entity is null) - return Result.Fail(new NotFoundError("Шаг маршрута", request.Id)); + return Result.Fail(new NotFoundError("Шаг маршрута", id)); db.TemplateRouteSteps.Remove(entity); await db.SaveChangesAsync(cancellationToken); return Result.Ok(); } -} -internal static class TemplateRouteStepValidation -{ - public static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject) + private static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject) { if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && projectRole == ProjectRole.None) return new ValidationError("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); @@ -112,8 +110,8 @@ internal static class TemplateRouteStepValidation return null; } - public static async Task> AppendMissingProjectRoleWarningAsync( - ApprovalRouteDbContext db, TemplateRoute route, Result result, CancellationToken cancellationToken) + private async Task> AppendMissingProjectRoleWarningAsync( + TemplateRoute route, Result result, CancellationToken cancellationToken) { if (!route.ByProject) return result; @@ -125,10 +123,7 @@ internal static class TemplateRouteStepValidation ? result : result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте.")); } -} -internal static class TemplateRouteStepMapper -{ - public 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); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs deleted file mode 100644 index 5e83386..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteQuery.cs +++ /dev/null @@ -1,95 +0,0 @@ -using FluentResults; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Application.Common.Errors; -using Tnsu.ApprovalRoute.Application.Common.Interfaces; -using Tnsu.ApprovalRoute.Domain.Entities; -using Tnsu.ApprovalRoute.Domain.Enums; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; -using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; - -namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; - -public sealed record ResolveTemplateRouteQuery( - Guid RequestTypeId, - string? UserEmail, - Guid? ProjectId, - RouteStepStatus Status) : IRequest>; - -public sealed class ResolveTemplateRouteHandler( - ApprovalRouteDbContext db, - HttpTnsuPortalClient portalClient, - ICurrentUser currentUser) - : IRequestHandler> -{ - public async Task> Handle(ResolveTemplateRouteQuery request, CancellationToken cancellationToken) - { - if (request.Status == RouteStepStatus.None) - return Result.Fail( - new ValidationError("Статус обязателен: Approval или Execution.")); - - var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail; - if (string.IsNullOrWhiteSpace(email)) - return Result.Fail( - new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); - - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken); - if (!requestTypeExists) - return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); - - var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); - var byProject = request.ProjectId is not null; - - var route = await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) - .Where(x => - x.RequestTypeId == request.RequestTypeId && - x.CompanyId == employee.CompanyId && - x.ByProject == byProject && - x.DepartmentId == employee.DepartmentId) - .FirstOrDefaultAsync(cancellationToken) - ?? await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) - .Where(x => - x.RequestTypeId == request.RequestTypeId && - x.CompanyId == employee.CompanyId && - x.ByProject == byProject && - x.DepartmentId == null) - .FirstOrDefaultAsync(cancellationToken); - - if (route is null) - return Result.Fail(new NotFoundError( - "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", - request.RequestTypeId)); - - IReadOnlyList projectExecutors = []; - if (request.ProjectId is { } projectId) - projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); - - var steps = route.Steps - .Where(s => s.Status == request.Status) - .OrderBy(s => s.OrderNo) - .Select(s => ResolveStep(s, projectExecutors)) - .ToList(); - - return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps)); - } - - private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) - { - // Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz), - // а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту. - if (step.ProjectRole != ProjectRole.None) - { - var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); - if (projectExecutor is not null) - return new ResolvedRouteStepDto( - step.Id, step.OrderNo, projectExecutor.Email, projectExecutor.Position, - step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); - } - - return new ResolvedRouteStepDto( - step.Id, step.OrderNo, step.ExecutorEmail, step.Position, - step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); - } -} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs deleted file mode 100644 index 19c54a0..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs +++ /dev/null @@ -1,122 +0,0 @@ -using FluentResults; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Application.Common.Errors; -using Tnsu.ApprovalRoute.Domain.Entities; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; - -namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; - -public sealed record CreateTemplateRouteCommand(CreateTemplateRouteRequest Request) : IRequest>; - -public sealed record UpdateTemplateRouteCommand(Guid Id, UpdateTemplateRouteRequest Request) : IRequest>; - -public sealed record DeleteTemplateRouteCommand(Guid Id) : IRequest; - -public sealed class CreateTemplateRouteHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(CreateTemplateRouteCommand command, CancellationToken cancellationToken) - { - var r = command.Request; - - if (r.CompanyId == Guid.Empty) - return Result.Fail(new ValidationError("Компания обязательна.")); - - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); - if (!requestTypeExists) - return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); - - var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync( - db, r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); - if (duplicate) - return Result.Fail(new ConflictError( - "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); - - var entity = new TemplateRoute - { - RouteType = r.RouteType, - RequestTypeId = r.RequestTypeId, - DepartmentId = r.DepartmentId, - CompanyId = r.CompanyId, - ByProject = r.ByProject, - RequiresControl = r.RequiresControl - }; - db.TemplateRoutes.Add(entity); - await db.SaveChangesAsync(cancellationToken); - - return Result.Ok(TemplateRouteMapper.ToDto(entity)); - } -} - -public sealed class UpdateTemplateRouteHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(UpdateTemplateRouteCommand command, CancellationToken cancellationToken) - { - var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken); - if (entity is null) - return Result.Fail(new NotFoundError("Маршрут", command.Id)); - - var r = command.Request; - - if (r.CompanyId == Guid.Empty) - return Result.Fail(new ValidationError("Компания обязательна.")); - - var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync( - db, entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); - if (duplicate) - return Result.Fail(new ConflictError( - "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); - - entity.RouteType = r.RouteType; - entity.DepartmentId = r.DepartmentId; - entity.CompanyId = r.CompanyId; - entity.ByProject = r.ByProject; - entity.RequiresControl = r.RequiresControl; - await db.SaveChangesAsync(cancellationToken); - - return Result.Ok(TemplateRouteMapper.ToDto(entity)); - } -} - -public sealed class DeleteTemplateRouteHandler(ApprovalRouteDbContext db) - : IRequestHandler -{ - public async Task Handle(DeleteTemplateRouteCommand request, CancellationToken cancellationToken) - { - var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - if (entity is null) - return Result.Fail(new NotFoundError("Маршрут", request.Id)); - - db.TemplateRoutes.Remove(entity); - await db.SaveChangesAsync(cancellationToken); - - return Result.Ok(); - } -} - -internal static class TemplateRouteUniqueness -{ - public static Task HasDuplicateAsync( - ApprovalRouteDbContext db, - Guid requestTypeId, - Guid? departmentId, - Guid companyId, - bool byProject, - Guid? excludeId, - CancellationToken cancellationToken) => - db.TemplateRoutes.AnyAsync(x => - x.Id != excludeId && - x.RequestTypeId == requestTypeId && - x.DepartmentId == departmentId && - x.CompanyId == companyId && - x.ByProject == byProject, - cancellationToken); -} - -internal static class TemplateRouteMapper -{ - public static TemplateRouteDto ToDto(TemplateRoute e) => new( - e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); -} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs deleted file mode 100644 index 54e417a..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs +++ /dev/null @@ -1,43 +0,0 @@ -using FluentResults; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Application.Common.Errors; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; - -namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; - -public sealed record GetTemplateRouteQuery(Guid Id) : IRequest>; - -public sealed record ListTemplateRoutesQuery(Guid? RequestTypeId) : IRequest>>; - -public sealed class GetTemplateRouteHandler(ApprovalRouteDbContext db) - : IRequestHandler> -{ - public async Task> Handle(GetTemplateRouteQuery request, CancellationToken cancellationToken) - { - var entity = await db.TemplateRoutes.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); - - return entity is null - ? Result.Fail(new NotFoundError("Маршрут", request.Id)) - : Result.Ok(TemplateRouteMapper.ToDto(entity)); - } -} - -public sealed class ListTemplateRoutesHandler(ApprovalRouteDbContext db) - : IRequestHandler>> -{ - public async Task>> Handle(ListTemplateRoutesQuery request, CancellationToken cancellationToken) - { - var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); - if (request.RequestTypeId is { } requestTypeId) - query = query.Where(x => x.RequestTypeId == requestTypeId); - - var items = await query - .Select(x => new TemplateRouteDto( - x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) - .ToListAsync(cancellationToken); - - return Result.Ok>(items); - } -} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs new file mode 100644 index 0000000..4fa96d8 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -0,0 +1,189 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Tnsu.ApprovalRoute.Application.Common.Errors; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed class TemplateRouteService( + ApprovalRouteDbContext db, + HttpTnsuPortalClient portalClient, + ICurrentUser currentUser) +{ + public async Task>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken) + { + var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); + if (requestTypeId is { } id) + query = query.Where(x => x.RequestTypeId == id); + + var items = await query + .Select(x => new TemplateRouteDto( + x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) + .ToListAsync(cancellationToken); + + return Result.Ok>(items); + } + + public async Task> GetAsync(Guid id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Маршрут", id)) + : Result.Ok(ToDto(entity)); + } + + public async Task> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken) + { + if (r.CompanyId == Guid.Empty) + return Result.Fail(new ValidationError("Компания обязательна.")); + + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); + + var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + var entity = new TemplateRoute + { + RouteType = r.RouteType, + RequestTypeId = r.RequestTypeId, + DepartmentId = r.DepartmentId, + CompanyId = r.CompanyId, + ByProject = r.ByProject, + RequiresControl = r.RequiresControl + }; + db.TemplateRoutes.Add(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task> UpdateAsync(Guid id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", id)); + + if (r.CompanyId == Guid.Empty) + return Result.Fail(new ValidationError("Компания обязательна.")); + + var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + entity.RouteType = r.RouteType; + entity.DepartmentId = r.DepartmentId; + entity.CompanyId = r.CompanyId; + entity.ByProject = r.ByProject; + entity.RequiresControl = r.RequiresControl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", id)); + + db.TemplateRoutes.Remove(entity); + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(); + } + + public async Task> ResolveAsync( + Guid requestTypeId, string? userEmail, Guid? projectId, RouteStepStatus status, CancellationToken cancellationToken) + { + if (status == RouteStepStatus.None) + return Result.Fail( + new ValidationError("Статус обязателен: Approval или Execution.")); + + var email = string.IsNullOrWhiteSpace(userEmail) ? currentUser.Email : userEmail; + if (string.IsNullOrWhiteSpace(email)) + return Result.Fail( + new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); + + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == requestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); + + var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); + var byProject = projectId is not null; + + var route = await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == requestTypeId && + x.CompanyId == employee.CompanyId && + x.ByProject == byProject && + x.DepartmentId == employee.DepartmentId) + .FirstOrDefaultAsync(cancellationToken) + ?? await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == requestTypeId && + x.CompanyId == employee.CompanyId && + x.ByProject == byProject && + x.DepartmentId == null) + .FirstOrDefaultAsync(cancellationToken); + + if (route is null) + return Result.Fail(new NotFoundError( + "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", + requestTypeId)); + + IReadOnlyList projectExecutors = []; + if (projectId is { } id) + projectExecutors = await portalClient.GetProjectExecutorsAsync(id, cancellationToken); + + var steps = route.Steps + .Where(s => s.Status == status) + .OrderBy(s => s.OrderNo) + .Select(s => ResolveStep(s, projectExecutors)) + .ToList(); + + return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps)); + } + + private Task HasDuplicateAsync( + Guid requestTypeId, Guid? departmentId, Guid companyId, bool byProject, Guid? excludeId, CancellationToken cancellationToken) => + db.TemplateRoutes.AnyAsync(x => + x.Id != excludeId && + x.RequestTypeId == requestTypeId && + x.DepartmentId == departmentId && + x.CompanyId == companyId && + x.ByProject == byProject, + cancellationToken); + + private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) + { + // Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz), + // а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту. + if (step.ProjectRole != ProjectRole.None) + { + var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); + if (projectExecutor is not null) + return new ResolvedRouteStepDto( + step.Id, step.OrderNo, projectExecutor.Email, projectExecutor.Position, + step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); + } + + return new ResolvedRouteStepDto( + step.Id, step.OrderNo, step.ExecutorEmail, step.Position, + step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); + } + + private static TemplateRouteDto ToDto(TemplateRoute e) => new( + e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index 409b6fe..9988106 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -5,7 +5,6 @@ - -- 2.34.1 From 253fafa575c80edaabf7948fdc5e97178e5c2cf7 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 16:58:18 +0500 Subject: [PATCH 20/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BD=D0=B0=20fluentValidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 2 ++ .../Controllers/RequestTypesController.cs | 4 +-- .../Controllers/TemplateRoutesController.cs | 10 ++---- .../Filters/ValidationActionFilter.cs | 35 +++++++++++++++++++ backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 3 +- .../ApplicationServiceCollectionExtensions.cs | 4 +++ .../RequestTypes/RequestTypeService.cs | 20 +++-------- .../RequestTypes/RequestTypeValidators.cs | 21 +++++++++++ .../TemplateRouteStepService.cs | 23 ++++-------- .../TemplateRouteStepValidators.cs | 24 +++++++++++++ .../TemplateRoutes/ResolveTemplateRouteDto.cs | 6 ++++ .../TemplateRoutes/TemplateRouteService.cs | 32 ++++++----------- .../TemplateRoutes/TemplateRouteValidators.cs | 28 +++++++++++++++ .../Tnsu.ApprovalRoute.Application.csproj | 2 ++ 14 files changed, 150 insertions(+), 64 deletions(-) create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 55b277f..022fa01 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -6,6 +6,8 @@ + + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index 9c0644a..2b0d5a8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -20,11 +20,11 @@ public sealed class RequestTypesController(RequestTypeService service) : Control [HttpPost] public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => - (await service.CreateAsync(body.Name, body.PageUrl, ct)).ToActionResult(); + (await service.CreateAsync(body, ct)).ToActionResult(); [HttpPut("{id:guid}")] public async Task Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => - (await service.UpdateAsync(id, body.Name, body.PageUrl, ct)).ToActionResult(); + (await service.UpdateAsync(id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) => diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 473034f..368d8a7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Application.TemplateRoutes; -using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Api.Controllers; @@ -12,13 +11,8 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase { [HttpGet("resolve")] - public async Task Resolve( - [FromQuery] Guid requestTypeId, - [FromQuery] string? userEmail, - [FromQuery] Guid? projectId, - [FromQuery] RouteStepStatus status, - CancellationToken ct) => - (await service.ResolveAsync(requestTypeId, userEmail, projectId, status, ct)).ToActionResult(); + public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) => + (await service.ResolveAsync(request, ct)).ToActionResult(); [HttpGet] public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs new file mode 100644 index 0000000..ff4ecf4 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Tnsu.ApprovalRoute.Api.Filters; + +// Валидирует аргументы action'ов, для которых зарегистрирован IValidator — без валидации внутри сервисов. +public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + foreach (var argument in context.ActionArguments.Values) + { + if (argument is null) + continue; + + var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); + if (serviceProvider.GetService(validatorType) is not IValidator validator) + continue; + + var validationContext = new ValidationContext(argument); + var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted); + if (result.IsValid) + continue; + + context.Result = new BadRequestObjectResult(new + { + message = string.Join(" ", result.Errors.Select(e => e.ErrorMessage)) + }); + return; + } + + await next(); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 76e4a2d..27106b6 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi; using Tnsu.ApprovalRoute.Api.Auth; +using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Infrastructure; @@ -44,7 +45,7 @@ builder.Services.AddOpenApi(options => }); }); builder.Services.AddHealthChecks(); -builder.Services.AddControllers() +builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 63a6f1b..e466d09 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; @@ -13,6 +15,8 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + return services; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 6be017c..014b3a8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -27,33 +27,23 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) : Result.Ok(ToDto(entity)); } - public async Task> CreateAsync(string name, string pageUrl, CancellationToken cancellationToken) + public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(name)) - return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); - if (string.IsNullOrWhiteSpace(pageUrl)) - return Result.Fail(new ValidationError("Url страницы обязателен.")); - - var entity = new RequestType { Name = name, PageUrl = pageUrl }; + var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); } - public async Task> UpdateAsync(Guid id, string name, string pageUrl, CancellationToken cancellationToken) + public async Task> UpdateAsync(Guid id, UpdateRequestTypeRequest request, CancellationToken cancellationToken) { var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); - if (string.IsNullOrWhiteSpace(name)) - return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); - if (string.IsNullOrWhiteSpace(pageUrl)) - return Result.Fail(new ValidationError("Url страницы обязателен.")); - - entity.Name = name; - entity.PageUrl = pageUrl; + entity.Name = request.Name; + entity.PageUrl = request.PageUrl; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs new file mode 100644 index 0000000..cfd5bf4 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace Tnsu.ApprovalRoute.Application.RequestTypes; + +public sealed class CreateRequestTypeRequestValidator : AbstractValidator +{ + public CreateRequestTypeRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); + RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + } +} + +public sealed class UpdateRequestTypeRequestValidator : AbstractValidator +{ + public UpdateRequestTypeRequestValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); + RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index ae8c55f..0ba617a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -37,9 +37,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); - var fieldsError = ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); - if (fieldsError is not null) - return Result.Fail(fieldsError); + if (!route.ByProject && r.ProjectRole != ProjectRole.None) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); var entity = new TemplateRouteStep { @@ -70,9 +70,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Шаг маршрута", id)); - var fieldsError = ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject); - if (fieldsError is not null) - return Result.Fail(fieldsError); + if (!route.ByProject && r.ProjectRole != ProjectRole.None) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); entity.OrderNo = r.OrderNo; entity.ExecutorEmail = r.ExecutorEmail; @@ -99,17 +99,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(); } - private static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject) - { - if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && projectRole == ProjectRole.None) - return new ValidationError("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); - - if (!routeByProject && projectRole != ProjectRole.None) - return new ValidationError("Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."); - - return null; - } - private async Task> AppendMissingProjectRoleWarningAsync( TemplateRoute route, Result result, CancellationToken cancellationToken) { diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs new file mode 100644 index 0000000..be1d48b --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator +{ + public CreateTemplateRouteStepRequestValidator() + { + RuleFor(x => x) + .Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) + .WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + } +} + +public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator +{ + public UpdateTemplateRouteStepRequestValidator() + { + RuleFor(x => x) + .Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) + .WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 7f828dd..4868411 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -17,3 +17,9 @@ public sealed record ResolveTemplateRouteResultDto( RouteType RouteType, bool RequiresControl, IReadOnlyList Steps); + +public sealed record ResolveTemplateRouteRequest( + Guid RequestTypeId, + string? UserEmail, + Guid? ProjectId, + RouteStepStatus Status); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 4fa96d8..e2704d5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -39,9 +39,6 @@ public sealed class TemplateRouteService( public async Task> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken) { - if (r.CompanyId == Guid.Empty) - return Result.Fail(new ValidationError("Компания обязательна.")); - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); if (!requestTypeExists) return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); @@ -72,9 +69,6 @@ public sealed class TemplateRouteService( if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); - if (r.CompanyId == Guid.Empty) - return Result.Fail(new ValidationError("Компания обязательна.")); - var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( @@ -103,28 +97,24 @@ public sealed class TemplateRouteService( } public async Task> ResolveAsync( - Guid requestTypeId, string? userEmail, Guid? projectId, RouteStepStatus status, CancellationToken cancellationToken) + ResolveTemplateRouteRequest request, CancellationToken cancellationToken) { - if (status == RouteStepStatus.None) - return Result.Fail( - new ValidationError("Статус обязателен: Approval или Execution.")); - - var email = string.IsNullOrWhiteSpace(userEmail) ? currentUser.Email : userEmail; + var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail; if (string.IsNullOrWhiteSpace(email)) return Result.Fail( new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене.")); - var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == requestTypeId, cancellationToken); + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken); if (!requestTypeExists) - return Result.Fail(new NotFoundError("Тип заявки", requestTypeId)); + return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); - var byProject = projectId is not null; + var byProject = request.ProjectId is not null; var route = await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => - x.RequestTypeId == requestTypeId && + x.RequestTypeId == request.RequestTypeId && x.CompanyId == employee.CompanyId && x.ByProject == byProject && x.DepartmentId == employee.DepartmentId) @@ -132,7 +122,7 @@ public sealed class TemplateRouteService( ?? await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => - x.RequestTypeId == requestTypeId && + x.RequestTypeId == request.RequestTypeId && x.CompanyId == employee.CompanyId && x.ByProject == byProject && x.DepartmentId == null) @@ -141,14 +131,14 @@ public sealed class TemplateRouteService( if (route is null) return Result.Fail(new NotFoundError( "Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»", - requestTypeId)); + request.RequestTypeId)); IReadOnlyList projectExecutors = []; - if (projectId is { } id) - projectExecutors = await portalClient.GetProjectExecutorsAsync(id, cancellationToken); + if (request.ProjectId is { } projectId) + projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); var steps = route.Steps - .Where(s => s.Status == status) + .Where(s => s.Status == request.Status) .OrderBy(s => s.OrderNo) .Select(s => ResolveStep(s, projectExecutors)) .ToList(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs new file mode 100644 index 0000000..0088486 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using Tnsu.ApprovalRoute.Domain.Enums; + +namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; + +public sealed class CreateTemplateRouteRequestValidator : AbstractValidator +{ + public CreateTemplateRouteRequestValidator() + { + RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); + } +} + +public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator +{ + public UpdateTemplateRouteRequestValidator() + { + RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); + } +} + +public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator +{ + public ResolveTemplateRouteRequestValidator() + { + RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index 9988106..a10ee5e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -5,6 +5,8 @@ + + -- 2.34.1 From e201823f698cfe120f732886819e92839dce1203 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 17:21:30 +0500 Subject: [PATCH 21/38] =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D0=BE=20=D1=82=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BC=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 3 + .../RequestTypeServiceTests.cs | 96 +++++++++++ .../TemplateRouteServiceTests.cs | 161 ++++++++++++++++++ .../TemplateRouteStepServiceTests.cs | 154 +++++++++++++++++ .../Infrastructure/PostgresDatabaseFixture.cs | 59 +++++++ .../Infrastructure/TestCurrentUser.cs | 8 + .../TestServiceProviderFactory.cs | 29 ++++ .../Infrastructure/TestSettings.cs | 8 + .../RequestTypePostgresTests.cs | 40 ----- ...Tnsu.ApprovalRoute.IntegrationTests.csproj | 4 + 10 files changed, 522 insertions(+), 40 deletions(-) create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs create mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs delete mode 100644 backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 022fa01..59c877f 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -13,8 +13,11 @@ + + + diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs new file mode 100644 index 0000000..f6b9dc9 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +[CollectionDefinition(Name)] +public sealed class RequestTypeServiceCollection : ICollectionFixture +{ + public const string Name = nameof(RequestTypeServiceCollection); +} + +[Collection(RequestTypeServiceCollection.Name)] +public sealed class RequestTypeServiceTests : IDisposable +{ + private readonly ServiceProvider _services; + private readonly RequestTypeService _service; + + public RequestTypeServiceTests(PostgresDatabaseFixture fixture) + { + _services = TestServiceProviderFactory.Build(fixture.ConnectionString); + _service = _services.GetRequiredService(); + } + + public void Dispose() => _services.Dispose(); + + [Fact] + public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() + { + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None); + + created.IsSuccess.ShouldBeTrue(); + created.Value.Name.ShouldBe("Vacation"); + created.Value.PageUrl.ShouldBe("/vacation"); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + + fetched.IsSuccess.ShouldBeTrue(); + fetched.Value.Id.ShouldBe(created.Value.Id); + fetched.Value.Name.ShouldBe("Vacation"); + } + + [Fact] + public async Task GetAsync_UnknownId_ReturnsFailedResult() + { + var result = await _service.GetAsync(Guid.NewGuid(), CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task UpdateAsync_ChangesFields() + { + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None); + + var updated = await _service.UpdateAsync( + created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None); + + updated.IsSuccess.ShouldBeTrue(); + updated.Value.Name.ShouldBe("New name"); + updated.Value.PageUrl.ShouldBe("/new"); + } + + [Fact] + public async Task UpdateAsync_UnknownId_ReturnsFailedResult() + { + var result = await _service.UpdateAsync( + Guid.NewGuid(), new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task DeleteAsync_RemovesEntity() + { + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None); + + var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); + deleted.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + fetched.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task ListAsync_IncludesCreatedEntity() + { + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None); + + var list = await _service.ListAsync(CancellationToken.None); + + list.IsSuccess.ShouldBeTrue(); + list.Value.ShouldContain(x => x.Id == created.Value.Id); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs new file mode 100644 index 0000000..a982cae --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -0,0 +1,161 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +[CollectionDefinition(Name)] +public sealed class TemplateRouteServiceCollection : ICollectionFixture +{ + public const string Name = nameof(TemplateRouteServiceCollection); +} + +[Collection(TemplateRouteServiceCollection.Name)] +public sealed class TemplateRouteServiceTests : IDisposable +{ + private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + private readonly ServiceProvider _services; + private readonly TemplateRouteService _service; + private readonly RequestTypeService _requestTypeService; + + public TemplateRouteServiceTests(PostgresDatabaseFixture fixture) + { + _services = TestServiceProviderFactory.Build(fixture.ConnectionString); + _service = _services.GetRequiredService(); + _requestTypeService = _services.GetRequiredService(); + } + + public void Dispose() => _services.Dispose(); + + private async Task CreateRequestTypeAsync(string name) => + (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; + + [Fact] + public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() + { + var requestTypeId = await CreateRequestTypeAsync("Vacation"); + + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), + CancellationToken.None); + + created.IsSuccess.ShouldBeTrue(); + created.Value.RequestTypeId.ShouldBe(requestTypeId); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + fetched.IsSuccess.ShouldBeTrue(); + fetched.Value.CompanyId.ShouldBe(CompanyId); + } + + [Fact] + public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult() + { + var result = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, Guid.NewGuid(), DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult() + { + var requestTypeId = await CreateRequestTypeAsync("Business trip"); + var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false); + + var first = await _service.CreateAsync(request, CancellationToken.None); + first.IsSuccess.ShouldBeTrue(); + + var duplicate = await _service.CreateAsync(request, CancellationToken.None); + duplicate.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task UpdateAsync_ChangesFields() + { + var requestTypeId = await CreateRequestTypeAsync("Sick leave"); + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var updated = await _service.UpdateAsync( + created.Value.Id, + new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), + CancellationToken.None); + + updated.IsSuccess.ShouldBeTrue(); + updated.Value.RouteType.ShouldBe(RouteType.Parallel); + updated.Value.RequiresControl.ShouldBeTrue(); + } + + [Fact] + public async Task DeleteAsync_RemovesEntity() + { + var requestTypeId = await CreateRequestTypeAsync("Remote work"); + var created = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); + deleted.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + fetched.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus() + { + var requestTypeId = await CreateRequestTypeAsync("Exact match request"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new Application.TemplateRouteSteps.CreateTemplateRouteStepRequest( + 1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false), + CancellationToken.None); + + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.RouteId.ShouldBe(route.Value.Id); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); + } + + [Fact] + public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() + { + var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution), + CancellationToken.None); + + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.RouteId.ShouldBe(route.Value.Id); + } + + [Fact] + public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult() + { + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(Guid.NewGuid(), "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + resolved.IsFailed.ShouldBeTrue(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs new file mode 100644 index 0000000..4b59a9b --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -0,0 +1,154 @@ +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Tnsu.ApprovalRoute.Application.RequestTypes; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; + +[CollectionDefinition(Name)] +public sealed class TemplateRouteStepServiceCollection : ICollectionFixture +{ + public const string Name = nameof(TemplateRouteStepServiceCollection); +} + +[Collection(TemplateRouteStepServiceCollection.Name)] +public sealed class TemplateRouteStepServiceTests : IDisposable +{ + private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + + private readonly ServiceProvider _services; + private readonly TemplateRouteStepService _service; + private readonly TemplateRouteService _routeService; + private readonly RequestTypeService _requestTypeService; + + public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture) + { + _services = TestServiceProviderFactory.Build(fixture.ConnectionString); + _service = _services.GetRequiredService(); + _routeService = _services.GetRequiredService(); + _requestTypeService = _services.GetRequiredService(); + } + + public void Dispose() => _services.Dispose(); + + private async Task CreateRouteAsync(string requestTypeName, bool byProject) + { + var requestTypeId = (await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; + + var route = await _routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), + CancellationToken.None); + + return route.Value.Id; + } + + [Fact] + public async Task CreateAsync_UnknownRoute_ReturnsFailedResult() + { + var result = await _service.CreateAsync( + Guid.NewGuid(), + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult() + { + var routeId = await CreateRouteAsync("Non project route", byProject: false); + + var result = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + result.IsFailed.ShouldBeTrue(); + } + + [Fact] + public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep() + { + var routeId = await CreateRouteAsync("Simple route", byProject: false); + + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + created.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None); + fetched.IsSuccess.ShouldBeTrue(); + fetched.Value.ExecutorEmail.ShouldBe("a@b.kz"); + } + + [Fact] + public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning() + { + var routeId = await CreateRouteAsync("By-project route without role", byProject: true); + + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + created.IsSuccess.ShouldBeTrue(); + created.Reasons.ShouldContain(r => r.Message.Contains("ролью в проекте")); + } + + [Fact] + public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning() + { + var routeId = await CreateRouteAsync("By-project route with role", byProject: true); + + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + created.IsSuccess.ShouldBeTrue(); + created.Reasons.ShouldBeEmpty(); + } + + [Fact] + public async Task UpdateAsync_ChangesFields() + { + var routeId = await CreateRouteAsync("Route to update step", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + var updated = await _service.UpdateAsync( + routeId, + created.Value.Id, + new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Execution, true), + CancellationToken.None); + + updated.IsSuccess.ShouldBeTrue(); + updated.Value.ExecutorEmail.ShouldBe("b@b.kz"); + updated.Value.Status.ShouldBe(RouteStepStatus.Execution); + updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue(); + } + + [Fact] + public async Task DeleteAsync_RemovesStep() + { + var routeId = await CreateRouteAsync("Route to delete step", byProject: false); + var created = await _service.CreateAsync( + routeId, + new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None); + deleted.IsSuccess.ShouldBeTrue(); + + var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None); + fetched.IsFailed.ShouldBeTrue(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs new file mode 100644 index 0000000..cb76a16 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/PostgresDatabaseFixture.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Xunit; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +// Отдельная БД на один xUnit-collection (= один тестовый класс) в общем инстансе Postgres. +// Тесты внутри класса делят эту БД; разные классы (разные коллекции) получают разные БД. +public sealed class PostgresDatabaseFixture : IAsyncLifetime +{ + private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}"; + private readonly NpgsqlConnectionStringBuilder _adminConnectionBuilder = new(TestSettings.BaseConnectionString); + + public string ConnectionString { get; private set; } = string.Empty; + + public async Task InitializeAsync() + { + await using (var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString)) + { + await adminConnection.OpenAsync(); + await using var createCommand = new NpgsqlCommand($"CREATE DATABASE \"{_databaseName}\"", adminConnection); + await createCommand.ExecuteNonQueryAsync(); + } + + ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionBuilder.ConnectionString) + { + Database = _databaseName + }.ConnectionString; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseSnakeCaseNamingConvention() + .Options; + + await using var db = new ApprovalRouteDbContext(options); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await using var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString); + await adminConnection.OpenAsync(); + + await using (var terminateCommand = new NpgsqlCommand( + """ + SELECT pg_terminate_backend(pid) FROM pg_stat_activity + WHERE datname = @databaseName AND pid <> pg_backend_pid(); + """, + adminConnection)) + { + terminateCommand.Parameters.AddWithValue("databaseName", _databaseName); + await terminateCommand.ExecuteNonQueryAsync(); + } + + await using var dropCommand = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{_databaseName}\"", adminConnection); + await dropCommand.ExecuteNonQueryAsync(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs new file mode 100644 index 0000000..020d450 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestCurrentUser.cs @@ -0,0 +1,8 @@ +using Tnsu.ApprovalRoute.Application.Common.Interfaces; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +internal sealed class TestCurrentUser(string? email = null) : ICurrentUser +{ + public string? Email { get; } = email; +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs new file mode 100644 index 0000000..1290809 --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestServiceProviderFactory.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Tnsu.ApprovalRoute.Application; +using Tnsu.ApprovalRoute.Application.Common.Interfaces; +using Tnsu.ApprovalRoute.Infrastructure; + +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +// Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs), +// но с тестовой БД и тестовым ICurrentUser вместо HttpContext-based реализации. +internal static class TestServiceProviderFactory +{ + public static ServiceProvider Build(string connectionString, string? currentUserEmail = "initiator@tnsu.kz") + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:Postgres"] = connectionString + }) + .Build(); + + var services = new ServiceCollection(); + services.AddApprovalRouteInfrastructure(configuration); + services.AddApprovalRouteApplication(); + services.AddSingleton(new TestCurrentUser(currentUserEmail)); + + return services.BuildServiceProvider(); + } +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs new file mode 100644 index 0000000..da4f2dc --- /dev/null +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Infrastructure/TestSettings.cs @@ -0,0 +1,8 @@ +namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; + +internal static class TestSettings +{ + public static string BaseConnectionString => + Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") + ?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"; +} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs deleted file mode 100644 index 43f4131..0000000 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/RequestTypePostgresTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Tnsu.ApprovalRoute.Domain.Entities; -using Tnsu.ApprovalRoute.Infrastructure.Persistence; - -namespace Tnsu.ApprovalRoute.IntegrationTests; - -public class RequestTypePostgresTests -{ - private static string ConnectionString => - Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION") - ?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"; - - [Fact] - public async Task CanInsertAndReadBack_RealPostgres() - { - var options = new DbContextOptionsBuilder() - .UseNpgsql(ConnectionString) - .UseSnakeCaseNamingConvention() - .Options; - - await using var db = new ApprovalRouteDbContext(options); - await db.Database.MigrateAsync(); - - // Транзакция без коммита — тест не оставляет данных в базе после себя. - await using var transaction = await db.Database.BeginTransactionAsync(); - - var entity = new RequestType { Name = "Integration test type", PageUrl = "/integration-test" }; - db.RequestTypes.Add(entity); - await db.SaveChangesAsync(); - - db.ChangeTracker.Clear(); - var loaded = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == entity.Id); - - Assert.NotNull(loaded); - Assert.Equal("Integration test type", loaded.Name); - Assert.Equal("/integration-test", loaded.PageUrl); - - await transaction.RollbackAsync(); - } -} diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj index efe1f0d..1ee25ea 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj @@ -6,11 +6,15 @@ + + + + -- 2.34.1 From 0bf5475553a76f13a8e46f93cd57b481009b4836 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 17:38:14 +0500 Subject: [PATCH 22/38] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D1=87=D0=B5=D1=81=D0=B0=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RequestTypeServiceTests.cs | 45 ++++++++------- .../TemplateRouteServiceTests.cs | 52 +++++++++++------- .../TemplateRouteStepServiceTests.cs | 55 +++++++++++-------- 3 files changed, 92 insertions(+), 60 deletions(-) diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index f6b9dc9..001b54e 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -5,91 +5,98 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; -[CollectionDefinition(Name)] -public sealed class RequestTypeServiceCollection : ICollectionFixture +public sealed class RequestTypeServiceTests : IClassFixture { - public const string Name = nameof(RequestTypeServiceCollection); -} - -[Collection(RequestTypeServiceCollection.Name)] -public sealed class RequestTypeServiceTests : IDisposable -{ - private readonly ServiceProvider _services; private readonly RequestTypeService _service; public RequestTypeServiceTests(PostgresDatabaseFixture fixture) { - _services = TestServiceProviderFactory.Build(fixture.ConnectionString); - _service = _services.GetRequiredService(); + _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); } - public void Dispose() => _services.Dispose(); - - [Fact] + [Fact(DisplayName = "Создание типа заявки и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() { + // Action var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None); + // Assert created.IsSuccess.ShouldBeTrue(); created.Value.Name.ShouldBe("Vacation"); created.Value.PageUrl.ShouldBe("/vacation"); + // Action var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); + // Assert fetched.IsSuccess.ShouldBeTrue(); fetched.Value.Id.ShouldBe(created.Value.Id); fetched.Value.Name.ShouldBe("Vacation"); } - [Fact] + [Fact(DisplayName = "Получение несуществующего типа заявки возвращает неуспешный результат")] public async Task GetAsync_UnknownId_ReturnsFailedResult() { + // Action var result = await _service.GetAsync(Guid.NewGuid(), CancellationToken.None); + // Assert result.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Обновление типа заявки изменяет имя и url страницы")] public async Task UpdateAsync_ChangesFields() { + // Arrange var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None); + // Action var updated = await _service.UpdateAsync( created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None); + // Assert updated.IsSuccess.ShouldBeTrue(); updated.Value.Name.ShouldBe("New name"); updated.Value.PageUrl.ShouldBe("/new"); } - [Fact] + [Fact(DisplayName = "Обновление несуществующего типа заявки возвращает неуспешный результат")] public async Task UpdateAsync_UnknownId_ReturnsFailedResult() { + // Action var result = await _service.UpdateAsync( Guid.NewGuid(), new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); + // Assert result.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Удаление типа заявки убирает его из базы")] public async Task DeleteAsync_RemovesEntity() { + // Arrange var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None); + // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); + + // Assert deleted.IsSuccess.ShouldBeTrue(); var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); fetched.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")] public async Task ListAsync_IncludesCreatedEntity() { + // Arrange var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None); + // Action var list = await _service.ListAsync(CancellationToken.None); + // Assert list.IsSuccess.ShouldBeTrue(); list.Value.ShouldContain(x => x.Id == created.Value.Id); } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index a982cae..34a8129 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -7,14 +7,7 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; -[CollectionDefinition(Name)] -public sealed class TemplateRouteServiceCollection : ICollectionFixture -{ - public const string Name = nameof(TemplateRouteServiceCollection); -} - -[Collection(TemplateRouteServiceCollection.Name)] -public sealed class TemplateRouteServiceTests : IDisposable +public sealed class TemplateRouteServiceTests : IClassFixture { private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); @@ -30,20 +23,21 @@ public sealed class TemplateRouteServiceTests : IDisposable _requestTypeService = _services.GetRequiredService(); } - public void Dispose() => _services.Dispose(); - private async Task CreateRequestTypeAsync(string name) => (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; - [Fact] + [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() { + // Arrange var requestTypeId = await CreateRequestTypeAsync("Vacation"); + // Action var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), CancellationToken.None); + // Assert created.IsSuccess.ShouldBeTrue(); created.Value.RequestTypeId.ShouldBe(requestTypeId); @@ -52,65 +46,78 @@ public sealed class TemplateRouteServiceTests : IDisposable fetched.Value.CompanyId.ShouldBe(CompanyId); } - [Fact] + [Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")] public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult() { + // Action var result = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, Guid.NewGuid(), DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); + // Assert result.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")] public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult() { + // Arrange var requestTypeId = await CreateRequestTypeAsync("Business trip"); var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false); - var first = await _service.CreateAsync(request, CancellationToken.None); first.IsSuccess.ShouldBeTrue(); + // Action var duplicate = await _service.CreateAsync(request, CancellationToken.None); + + // Assert duplicate.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")] public async Task UpdateAsync_ChangesFields() { + // Arrange var requestTypeId = await CreateRequestTypeAsync("Sick leave"); var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); + // Action var updated = await _service.UpdateAsync( created.Value.Id, new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), CancellationToken.None); + // Assert updated.IsSuccess.ShouldBeTrue(); updated.Value.RouteType.ShouldBe(RouteType.Parallel); updated.Value.RequiresControl.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Удаление маршрута убирает его из базы")] public async Task DeleteAsync_RemovesEntity() { + // Arrange var requestTypeId = await CreateRequestTypeAsync("Remote work"); var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); + // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); + + // Assert deleted.IsSuccess.ShouldBeTrue(); var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); fetched.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")] public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus() { + // Arrange var requestTypeId = await CreateRequestTypeAsync("Exact match request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), @@ -123,39 +130,46 @@ public sealed class TemplateRouteServiceTests : IDisposable 1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false), CancellationToken.None); + // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); + // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.RouteId.ShouldBe(route.Value.Id); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); } - [Fact] + [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() { + // Arrange var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); + // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution), CancellationToken.None); + // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.RouteId.ShouldBe(route.Value.Id); } - [Fact] + [Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")] public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult() { + // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(Guid.NewGuid(), "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); + // Assert resolved.IsFailed.ShouldBeTrue(); } } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 4b59a9b..4268e7f 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -8,32 +8,22 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; -[CollectionDefinition(Name)] -public sealed class TemplateRouteStepServiceCollection : ICollectionFixture -{ - public const string Name = nameof(TemplateRouteStepServiceCollection); -} - -[Collection(TemplateRouteStepServiceCollection.Name)] -public sealed class TemplateRouteStepServiceTests : IDisposable +public sealed class TemplateRouteStepServiceTests : IClassFixture { private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); - private readonly ServiceProvider _services; private readonly TemplateRouteStepService _service; private readonly TemplateRouteService _routeService; private readonly RequestTypeService _requestTypeService; public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture) { - _services = TestServiceProviderFactory.Build(fixture.ConnectionString); - _service = _services.GetRequiredService(); - _routeService = _services.GetRequiredService(); - _requestTypeService = _services.GetRequiredService(); + var services = TestServiceProviderFactory.Build(fixture.ConnectionString); + _service = services.GetRequiredService(); + _routeService = services.GetRequiredService(); + _requestTypeService = services.GetRequiredService(); } - public void Dispose() => _services.Dispose(); - private async Task CreateRouteAsync(string requestTypeName, bool byProject) { var requestTypeId = (await _requestTypeService.CreateAsync( @@ -46,40 +36,48 @@ public sealed class TemplateRouteStepServiceTests : IDisposable return route.Value.Id; } - [Fact] + [Fact(DisplayName = "Создание шага для несуществующего маршрута возвращает неуспешный результат")] public async Task CreateAsync_UnknownRoute_ReturnsFailedResult() { + // Action var result = await _service.CreateAsync( Guid.NewGuid(), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Assert result.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Роль в проекте недопустима для шага маршрута без признака «по проекту»")] public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult() { + // Arrange var routeId = await CreateRouteAsync("Non project route", byProject: false); + // Action var result = await _service.CreateAsync( routeId, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Assert result.IsFailed.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep() { + // Arrange var routeId = await CreateRouteAsync("Simple route", byProject: false); + // Action var created = await _service.CreateAsync( routeId, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Assert created.IsSuccess.ShouldBeTrue(); var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None); @@ -87,65 +85,78 @@ public sealed class TemplateRouteStepServiceTests : IDisposable fetched.Value.ExecutorEmail.ShouldBe("a@b.kz"); } - [Fact] + [Fact(DisplayName = "Создание шага без роли в проекте на маршруте «по проекту» возвращает предупреждение")] public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning() { + // Arrange var routeId = await CreateRouteAsync("By-project route without role", byProject: true); + // Action var created = await _service.CreateAsync( routeId, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Assert created.IsSuccess.ShouldBeTrue(); created.Reasons.ShouldContain(r => r.Message.Contains("ролью в проекте")); } - [Fact] + [Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")] public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning() { + // Arrange var routeId = await CreateRouteAsync("By-project route with role", byProject: true); + // Action var created = await _service.CreateAsync( routeId, new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Assert created.IsSuccess.ShouldBeTrue(); created.Reasons.ShouldBeEmpty(); } - [Fact] + [Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")] public async Task UpdateAsync_ChangesFields() { + // Arrange var routeId = await CreateRouteAsync("Route to update step", byProject: false); var created = await _service.CreateAsync( routeId, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Action var updated = await _service.UpdateAsync( routeId, created.Value.Id, new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Execution, true), CancellationToken.None); + // Assert updated.IsSuccess.ShouldBeTrue(); updated.Value.ExecutorEmail.ShouldBe("b@b.kz"); updated.Value.Status.ShouldBe(RouteStepStatus.Execution); updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue(); } - [Fact] + [Fact(DisplayName = "Удаление шага маршрута убирает его из базы")] public async Task DeleteAsync_RemovesStep() { + // Arrange var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var created = await _service.CreateAsync( routeId, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), CancellationToken.None); + // Action var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None); + + // Assert deleted.IsSuccess.ShouldBeTrue(); var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None); -- 2.34.1 From a1b83bcab210138a0ff8496e97eb5fee70b520d0 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 17:39:08 +0500 Subject: [PATCH 23/38] =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApplicationServices/RequestTypeServiceTests.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index 001b54e..83f8d38 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -5,14 +5,9 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; -public sealed class RequestTypeServiceTests : IClassFixture +public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : IClassFixture { - private readonly RequestTypeService _service; - - public RequestTypeServiceTests(PostgresDatabaseFixture fixture) - { - _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); - } + private readonly RequestTypeService _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService(); [Fact(DisplayName = "Создание типа заявки и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() -- 2.34.1 From 2759c1ef7df93be05211350a5401e5fa638d9dc4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 18:02:18 +0500 Subject: [PATCH 24/38] main master --- .gitea/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index fa3ff8b..4fce20c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - master pull_request: workflow_dispatch: -- 2.34.1 From fa13e931904b896df0324595c38f5f19516ea9d9 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 12:27:42 +0500 Subject: [PATCH 25/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 + .../Common/ApiResponse.cs | 5 ++ .../Common/ResultExtensions.cs | 10 +-- .../Controllers/RequestTypesController.cs | 11 +++ .../TemplateRouteStepsController.cs | 12 +++ .../Controllers/TemplateRoutesController.cs | 17 +++++ .../Filters/ValidationActionFilter.cs | 7 +- .../TemplateRoutes/TemplateRouteDto.cs | 12 +-- .../TemplateRoutes/TemplateRouteService.cs | 47 +++++++----- .../TemplateRoutes/TemplateRouteValidators.cs | 4 +- .../Entities/TemplateRoute.cs | 7 +- .../Enums/ProjectRole.cs | 9 ++- ...frastructureServiceCollectionExtensions.cs | 2 +- .../Persistence/ApprovalRouteDbContext.cs | 4 +- ... 20260724071302_InitialCreate.Designer.cs} | 21 ++--- ...ate.cs => 20260724071302_InitialCreate.cs} | 8 +- .../ApprovalRouteDbContextModelSnapshot.cs | 19 +++-- .../TnsuPortal/HttpTnsuPortalClient.cs | 76 ++++++++++++++++--- .../TnsuPortal/ITnsuPortalClient.cs | 8 ++ .../TnsuPortal/TnsuPortalDtos.cs | 4 +- .../TemplateRouteServiceTests.cs | 66 ++++++++++++---- .../TemplateRouteStepServiceTests.cs | 8 +- .../TestServiceProviderFactory.cs | 11 ++- ...Tnsu.ApprovalRoute.IntegrationTests.csproj | 1 + 24 files changed, 271 insertions(+), 99 deletions(-) create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260723074943_InitialCreate.Designer.cs => 20260724071302_InitialCreate.Designer.cs} (91%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260723074943_InitialCreate.cs => 20260724071302_InitialCreate.cs} (92%) create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 59c877f..7da68cc 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs new file mode 100644 index 0000000..36f539e --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs @@ -0,0 +1,5 @@ +namespace Tnsu.ApprovalRoute.Api.Common; + +public sealed record ApiResponse(T Data, IReadOnlyList Warnings); + +public sealed record ApiError(string Message); diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs index f440b19..26c7bef 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs @@ -12,7 +12,7 @@ public static class ResultExtensions return result.Errors[0].ToActionResult(); var warnings = result.Reasons.OfType().Select(w => w.Message).ToList(); - return new OkObjectResult(new { data = result.Value, warnings }); + return new OkObjectResult(new ApiResponse(result.Value, warnings)); } public static IActionResult ToActionResult(this Result result) @@ -25,9 +25,9 @@ public static class ResultExtensions private static IActionResult ToActionResult(this IError error) => error switch { - NotFoundError => new NotFoundObjectResult(new { message = error.Message }), - ValidationError => new BadRequestObjectResult(new { message = error.Message }), - ConflictError => new ConflictObjectResult(new { message = error.Message }), - _ => new ObjectResult(new { message = error.Message }) { StatusCode = StatusCodes.Status500InternalServerError } + NotFoundError => new NotFoundObjectResult(new ApiError(error.Message)), + ValidationError => new BadRequestObjectResult(new ApiError(error.Message)), + ConflictError => new ConflictObjectResult(new ApiError(error.Message)), + _ => new ObjectResult(new ApiError(error.Message)) { StatusCode = StatusCodes.Status500InternalServerError } }; } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index 2b0d5a8..da668c7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -8,25 +8,36 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] [Route("api/request-types")] +[Produces("application/json")] public sealed class RequestTypesController(RequestTypeService service) : ControllerBase { [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task List(CancellationToken ct) => (await service.ListAsync(ct)).ToActionResult(); [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Get(Guid id, CancellationToken ct) => (await service.GetAsync(id, ct)).ToActionResult(); [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => (await service.CreateAsync(body, ct)).ToActionResult(); [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Delete(Guid id, CancellationToken ct) => (await service.DeleteAsync(id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs index 57d9e79..780f5a5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -8,25 +8,37 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] [Route("api/template-routes/{routeId:guid}/steps")] +[Produces("application/json")] public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase { [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task List(Guid routeId, CancellationToken ct) => (await service.ListAsync(routeId, ct)).ToActionResult(); [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Get(Guid routeId, Guid id, CancellationToken ct) => (await service.GetAsync(routeId, id, ct)).ToActionResult(); [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => (await service.CreateAsync(routeId, body, ct)).ToActionResult(); [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Delete(Guid routeId, Guid id, CancellationToken ct) => (await service.DeleteAsync(routeId, id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 368d8a7..0569bf7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -8,29 +8,46 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] [Route("api/template-routes")] +[Produces("application/json")] public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase { [HttpGet("resolve")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) => (await service.ResolveAsync(request, ct)).ToActionResult(); [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => (await service.ListAsync(requestTypeId, ct)).ToActionResult(); [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Get(Guid id, CancellationToken ct) => (await service.GetAsync(id, ct)).ToActionResult(); [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) => (await service.CreateAsync(body, ct)).ToActionResult(); [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] public async Task Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] public async Task Delete(Guid id, CancellationToken ct) => (await service.DeleteAsync(id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs index ff4ecf4..7e252cc 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Tnsu.ApprovalRoute.Api.Common; namespace Tnsu.ApprovalRoute.Api.Filters; @@ -23,10 +24,8 @@ public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : I if (result.IsValid) continue; - context.Result = new BadRequestObjectResult(new - { - message = string.Join(" ", result.Errors.Select(e => e.ErrorMessage)) - }); + context.Result = new BadRequestObjectResult( + new ApiError(string.Join(" ", result.Errors.Select(e => e.ErrorMessage)))); return; } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 7f7a518..34eef9d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -6,22 +6,22 @@ public sealed record TemplateRouteDto( Guid Id, RouteType RouteType, Guid RequestTypeId, - Guid? DepartmentId, - Guid CompanyId, + string? Department, + string Company, bool ByProject, bool RequiresControl); public sealed record CreateTemplateRouteRequest( RouteType RouteType, Guid RequestTypeId, - Guid? DepartmentId, - Guid CompanyId, + string? Department, + string Company, bool ByProject, bool RequiresControl); public sealed record UpdateTemplateRouteRequest( RouteType RouteType, - Guid? DepartmentId, - Guid CompanyId, + string? Department, + string Company, bool ByProject, bool RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index e2704d5..89df532 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -11,7 +11,7 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed class TemplateRouteService( ApprovalRouteDbContext db, - HttpTnsuPortalClient portalClient, + ITnsuPortalClient portalClient, ICurrentUser currentUser) { public async Task>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken) @@ -22,7 +22,7 @@ public sealed class TemplateRouteService( var items = await query .Select(x => new TemplateRouteDto( - x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) + x.Id, x.RouteType, x.RequestTypeId, x.Department, x.Company, x.ByProject, x.RequiresControl)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -43,7 +43,8 @@ public sealed class TemplateRouteService( if (!requestTypeExists) return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); - var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); + var department = Normalize(r.Department); + var duplicate = await HasDuplicateAsync(r.RequestTypeId, department, r.Company, r.ByProject, excludeId: null, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); @@ -52,8 +53,8 @@ public sealed class TemplateRouteService( { RouteType = r.RouteType, RequestTypeId = r.RequestTypeId, - DepartmentId = r.DepartmentId, - CompanyId = r.CompanyId, + Department = department, + Company = r.Company, ByProject = r.ByProject, RequiresControl = r.RequiresControl }; @@ -69,14 +70,15 @@ public sealed class TemplateRouteService( if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); - var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); + var department = Normalize(r.Department); + var duplicate = await HasDuplicateAsync(entity.RequestTypeId, department, r.Company, r.ByProject, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); entity.RouteType = r.RouteType; - entity.DepartmentId = r.DepartmentId; - entity.CompanyId = r.CompanyId; + entity.Department = department; + entity.Company = r.Company; entity.ByProject = r.ByProject; entity.RequiresControl = r.RequiresControl; await db.SaveChangesAsync(cancellationToken); @@ -109,23 +111,28 @@ public sealed class TemplateRouteService( return Result.Fail(new NotFoundError("Тип заявки", request.RequestTypeId)); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); + if (employee is null) + return Result.Fail( + new NotFoundError("Сотрудник по email в справочнике api.tnsu.kz не найден", Guid.Empty)); + var byProject = request.ProjectId is not null; + var department = Normalize(employee.Department); var route = await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => x.RequestTypeId == request.RequestTypeId && - x.CompanyId == employee.CompanyId && + x.Company == employee.Company && x.ByProject == byProject && - x.DepartmentId == employee.DepartmentId) + x.Department == department) .FirstOrDefaultAsync(cancellationToken) ?? await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => x.RequestTypeId == request.RequestTypeId && - x.CompanyId == employee.CompanyId && + x.Company == employee.Company && x.ByProject == byProject && - x.DepartmentId == null) + x.Department == null) .FirstOrDefaultAsync(cancellationToken); if (route is null) @@ -147,25 +154,25 @@ public sealed class TemplateRouteService( } private Task HasDuplicateAsync( - Guid requestTypeId, Guid? departmentId, Guid companyId, bool byProject, Guid? excludeId, CancellationToken cancellationToken) => + Guid requestTypeId, string? department, string company, bool byProject, Guid? excludeId, CancellationToken cancellationToken) => db.TemplateRoutes.AnyAsync(x => x.Id != excludeId && x.RequestTypeId == requestTypeId && - x.DepartmentId == departmentId && - x.CompanyId == companyId && + x.Department == department && + x.Company == company && x.ByProject == byProject, cancellationToken); private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) { - // Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz), - // а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту. + // Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz); + // должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона. if (step.ProjectRole != ProjectRole.None) { var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); if (projectExecutor is not null) return new ResolvedRouteStepDto( - step.Id, step.OrderNo, projectExecutor.Email, projectExecutor.Position, + step.Id, step.OrderNo, projectExecutor.Email, step.Position, step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); } @@ -174,6 +181,8 @@ public sealed class TemplateRouteService( step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); } + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + private static TemplateRouteDto ToDto(TemplateRoute e) => new( - e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); + e.Id, e.RouteType, e.RequestTypeId, e.Department, e.Company, e.ByProject, e.RequiresControl); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 0088486..01a22a9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -7,7 +7,7 @@ public sealed class CreateTemplateRouteRequestValidator : AbstractValidator x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); + RuleFor(x => x.Company).NotEmpty().WithMessage("Компания обязательна."); } } @@ -15,7 +15,7 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); + RuleFor(x => x.Company).NotEmpty().WithMessage("Компания обязательна."); } } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index e400384..9c0a675 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -8,9 +8,10 @@ public class TemplateRoute public RouteType RouteType { get; set; } = RouteType.Sequential; public Guid RequestTypeId { get; set; } - // Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД. - public Guid? DepartmentId { get; set; } - public Guid CompanyId { get; set; } + // Компания и отдел инициатора — названия из справочника AD (api-tnsu-portal /Employee/ByEmail), + // хранятся строками без FK в этой БД. Отдел опционален, компания обязательна. + public string? Department { get; set; } + public string Company { get; set; } = string.Empty; public bool ByProject { get; set; } public bool RequiresControl { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs index f27fc56..a9c7afd 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs @@ -1,9 +1,12 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; +// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal +// (project_manager / planner / manager_hse / security). public enum ProjectRole { None, - Manager, - Engineer, - Observer + ProjectManager, + Planner, + ManagerHSE, + Security } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index bf2a02e..cdef65a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -20,7 +20,7 @@ public static class InfrastructureServiceCollectionExtensions services.Configure(configuration.GetSection("TnsuPortal")); services.AddHttpClient(); - services.AddHttpClient((sp, client) => + services.AddHttpClient((sp, client) => { var options = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(options.BaseUrl); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index a746779..2721d86 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -22,8 +22,10 @@ public class ApprovalRouteDbContext(DbContextOptions opt { e.HasKey(x => x.Id); e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.Company).HasMaxLength(256).IsRequired(); + e.Property(x => x.Department).HasMaxLength(256); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); - e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique(); + e.HasIndex(x => new { x.RequestTypeId, x.Department, x.Company, x.ByProject }).IsUnique(); }); modelBuilder.Entity(e => diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.Designer.cs similarity index 91% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.Designer.cs index 5e82c93..a472fbc 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260723074943_InitialCreate")] + [Migration("20260724071302_InitialCreate")] partial class InitialCreate { /// @@ -61,13 +61,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("by_project"); - b.Property("CompanyId") - .HasColumnType("uuid") - .HasColumnName("company_id"); + b.Property("Company") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("company"); - b.Property("DepartmentId") - .HasColumnType("uuid") - .HasColumnName("department_id"); + b.Property("Department") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("department"); b.Property("RequestTypeId") .HasColumnType("uuid") @@ -86,9 +89,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + b.HasIndex("RequestTypeId", "Department", "Company", "ByProject") .IsUnique() - .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + .HasDatabaseName("ix_template_routes_request_type_id_department_company_by_proje"); b.ToTable("template_routes", (string)null); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.cs similarity index 92% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.cs index c9fede9..8645c1f 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260723074943_InitialCreate.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.cs @@ -31,8 +31,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations id = table.Column(type: "uuid", nullable: false), route_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), request_type_id = table.Column(type: "uuid", nullable: false), - department_id = table.Column(type: "uuid", nullable: true), - company_id = table.Column(type: "uuid", nullable: false), + department = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + company = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), by_project = table.Column(type: "boolean", nullable: false), requires_control = table.Column(type: "boolean", nullable: false) }, @@ -78,9 +78,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations columns: new[] { "route_id", "order_no" }); migrationBuilder.CreateIndex( - name: "ix_template_routes_request_type_id_department_id_company_id_by", + name: "ix_template_routes_request_type_id_department_company_by_proje", table: "template_routes", - columns: new[] { "request_type_id", "department_id", "company_id", "by_project" }, + columns: new[] { "request_type_id", "department", "company", "by_project" }, unique: true); } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index c2cea76..73d526e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -58,13 +58,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("by_project"); - b.Property("CompanyId") - .HasColumnType("uuid") - .HasColumnName("company_id"); + b.Property("Company") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("company"); - b.Property("DepartmentId") - .HasColumnType("uuid") - .HasColumnName("department_id"); + b.Property("Department") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("department"); b.Property("RequestTypeId") .HasColumnType("uuid") @@ -83,9 +86,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + b.HasIndex("RequestTypeId", "Department", "Company", "ByProject") .IsUnique() - .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + .HasDatabaseName("ix_template_routes_request_type_id_department_company_by_proje"); b.ToTable("template_routes", (string)null); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index f2f1488..621693d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -1,20 +1,74 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Tnsu.ApprovalRoute.Domain.Enums; + namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -// Клиент api.tnsu.kz (api-tnsu-portal). GetEmployeeByEmailAsync и GetProjectExecutorsAsync — заглушки: -// на момент написания у api-tnsu-portal нет эндпоинта GET /Employee/ByEmail (есть только ByIIN/Manager), -// а форма ответа GET /Project/{oid} в его openapi-спеке не типизирована. Заменить на реальные вызовы, -// когда эндпоинты будут готовы — токен уже прокидывается через TnsuPortalTokenProvider. -public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) +// Клиент api.tnsu.kz (api-tnsu-portal). Сервисный токен прокидывается через TnsuPortalTokenProvider. +public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) : ITnsuPortalClient { - public Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) + public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) { - return Task.FromResult(new EmployeeDirectoryInfo( - DepartmentId: Guid.Parse("00000000-0000-0000-0000-000000000001"), - CompanyId: Guid.Parse("00000000-0000-0000-0000-000000000002"))); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}"); + await AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + + var employee = await response.Content.ReadFromJsonAsync(cancellationToken); + if (employee is null) + return null; + + return new EmployeeDirectoryInfo(employee.Company ?? string.Empty, employee.Section ?? string.Empty); } - public Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) + public async Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) { - return Task.FromResult>([]); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/Project/{projectId}"); + await AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return []; + response.EnsureSuccessStatusCode(); + + var project = await response.Content.ReadFromJsonAsync(cancellationToken); + if (project is null) + return []; + + // Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security). + var executors = new List(); + AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager); + AddIfPresent(executors, ProjectRole.Planner, project.Planner); + AddIfPresent(executors, ProjectRole.ManagerHSE, project.ManagerHSE); + AddIfPresent(executors, ProjectRole.Security, project.Security); + + return executors; } + + private static void AddIfPresent(List executors, ProjectRole role, string? email) + { + if (!string.IsNullOrWhiteSpace(email)) + executors.Add(new ProjectExecutorInfo(role, email)); + } + + private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + private sealed record EmployeeResponse( + [property: JsonPropertyName("company")] string? Company, + [property: JsonPropertyName("section")] string? Section); + + private sealed record ProjectResponse( + [property: JsonPropertyName("projectManager")] string? ProjectManager, + [property: JsonPropertyName("planner")] string? Planner, + [property: JsonPropertyName("managerHSE")] string? ManagerHSE, + [property: JsonPropertyName("security")] string? Security); } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs new file mode 100644 index 0000000..1c21425 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -0,0 +1,8 @@ +namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +public interface ITnsuPortalClient +{ + Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); + + Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index befde6a..909cf2a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -public sealed record EmployeeDirectoryInfo(Guid DepartmentId, Guid CompanyId); +public sealed record EmployeeDirectoryInfo(string Company, string Department); -public sealed record ProjectExecutorInfo(string Email, string Position, ProjectRole ProjectRole); +public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 34a8129..59879e5 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -1,16 +1,19 @@ using Microsoft.Extensions.DependencyInjection; +using Moq; using Shouldly; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; public sealed class TemplateRouteServiceTests : IClassFixture { - private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); - private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + private const string Company = "ТОО TANSU Construction"; + private const string Department = "IT"; private readonly ServiceProvider _services; private readonly TemplateRouteService _service; @@ -18,7 +21,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(Company, Department)); + portalClient + .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); + + _services = TestServiceProviderFactory.Build(fixture.ConnectionString, portalClient: portalClient.Object); _service = _services.GetRequiredService(); _requestTypeService = _services.GetRequiredService(); } @@ -34,7 +45,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, - new Application.TemplateRouteSteps.CreateTemplateRouteStepRequest( - 1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false), CancellationToken.None); // Action @@ -142,13 +152,39 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz"); + } + [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var route = await _service.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false), + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department: null, Company, ByProject: false, RequiresControl: 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 4268e7f..2661278 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -10,7 +10,7 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; public sealed class TemplateRouteStepServiceTests : IClassFixture { - private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + private const string Company = "ТОО TANSU Construction"; private readonly TemplateRouteStepService _service; private readonly TemplateRouteService _routeService; @@ -30,7 +30,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture @@ -24,6 +28,9 @@ internal static class TestServiceProviderFactory services.AddApprovalRouteApplication(); services.AddSingleton(new TestCurrentUser(currentUserEmail)); + if (portalClient is not null) + services.AddSingleton(portalClient); + return services.BuildServiceProvider(); } } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj index 1ee25ea..4b9680b 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/Tnsu.ApprovalRoute.IntegrationTests.csproj @@ -14,6 +14,7 @@ + -- 2.34.1 From 97ca6c284edbc3f4a8cf331dff7f5722714a932e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 12:33:09 +0500 Subject: [PATCH 26/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BD=D0=B0=20=D1=81=D0=BA=D0=B0=D0=BB=D1=8F=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 2 +- backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 14 +++++++++----- .../Tnsu.ApprovalRoute.Api.csproj | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 7da68cc..64c8bfc 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -18,8 +18,8 @@ + - diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 27106b6..fef508c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi; +using Scalar.AspNetCore; using Tnsu.ApprovalRoute.Api.Auth; using Tnsu.ApprovalRoute.Api.Filters; using Tnsu.ApprovalRoute.Application; @@ -53,12 +54,15 @@ var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); - app.UseSwaggerUI(c => + + // Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security) + // и корректно прикрепляет Bearer-токен к запросам. + app.MapScalarApiReference(options => { - c.SwaggerEndpoint("/openapi/v1.json", "Tnsu Approval Route v1"); - c.RoutePrefix = string.Empty; - c.ConfigObject.AdditionalItems["persistAuthorization"] = true; - c.EnableTryItOutByDefault(); + options + .WithTitle("Tnsu Approval Route") + .AddPreferredSecuritySchemes("Bearer") + .AddHttpAuthentication("Bearer", _ => { }); }); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index a941843..da2a380 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -11,6 +11,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + -- 2.34.1 From 5e45c18817141473687fcd99df52a62870474f93 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 12:58:59 +0500 Subject: [PATCH 27/38] =?UTF-8?q?enum=20=D0=B2=20api=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index fef508c..d3200eb 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -48,6 +48,8 @@ builder.Services.AddOpenApi(options => builder.Services.AddHealthChecks(); builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); +// Тот же конвертер для Http.Json — их читает генератор OpenAPI, чтобы enum в схеме были строками, а не числами. +builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); -- 2.34.1 From 5e3ef814f3eac6399065c3bf27fc543c4e7f66ad Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 13:57:30 +0500 Subject: [PATCH 28/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=88=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/RequestTypesController.cs | 12 ++++---- .../TemplateRouteStepsController.cs | 18 ++++++------ .../Controllers/TemplateRoutesController.cs | 14 ++++----- .../Common/Errors/ApiErrors.cs | 2 +- .../RequestTypes/RequestTypeDto.cs | 2 +- .../RequestTypes/RequestTypeService.cs | 6 ++-- .../TemplateRouteStepDto.cs | 4 +-- .../TemplateRouteStepService.cs | 10 +++---- .../TemplateRoutes/ResolveTemplateRouteDto.cs | 6 ++-- .../TemplateRoutes/TemplateRouteDto.cs | 6 ++-- .../TemplateRoutes/TemplateRouteService.cs | 12 ++++---- .../Entities/RequestType.cs | 5 +--- .../Entities/TemplateRoute.cs | 4 +-- .../Entities/TemplateRouteStep.cs | 4 +-- ... 20260724085143_InitialCreate.Designer.cs} | 29 +++++++++++-------- ...ate.cs => 20260724085143_InitialCreate.cs} | 17 ++++++----- .../ApprovalRouteDbContextModelSnapshot.cs | 27 ++++++++++------- .../RequestTypeServiceTests.cs | 4 +-- .../TemplateRouteServiceTests.cs | 6 ++-- .../TemplateRouteStepServiceTests.cs | 4 +-- 20 files changed, 101 insertions(+), 91 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260724071302_InitialCreate.Designer.cs => 20260724085143_InitialCreate.Designer.cs} (89%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260724071302_InitialCreate.cs => 20260724085143_InitialCreate.cs} (82%) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index da668c7..4cef00c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -16,10 +16,10 @@ public sealed class RequestTypesController(RequestTypeService service) : Control public async Task List(CancellationToken ct) => (await service.ListAsync(ct)).ToActionResult(); - [HttpGet("{id:guid}")] + [HttpGet("{id:int}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Get(Guid id, CancellationToken ct) => + public async Task Get(int id, CancellationToken ct) => (await service.GetAsync(id, ct)).ToActionResult(); [HttpPost] @@ -28,16 +28,16 @@ public sealed class RequestTypesController(RequestTypeService service) : Control public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => (await service.CreateAsync(body, ct)).ToActionResult(); - [HttpPut("{id:guid}")] + [HttpPut("{id:int}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => + public async Task Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); - [HttpDelete("{id:guid}")] + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Delete(Guid id, CancellationToken ct) => + public async Task Delete(int id, CancellationToken ct) => (await service.DeleteAsync(id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs index 780f5a5..a7e6771 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -7,38 +7,38 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize] -[Route("api/template-routes/{routeId:guid}/steps")] +[Route("api/template-routes/{routeId:int}/steps")] [Produces("application/json")] public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase { [HttpGet] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task List(Guid routeId, CancellationToken ct) => + public async Task List(int routeId, CancellationToken ct) => (await service.ListAsync(routeId, ct)).ToActionResult(); - [HttpGet("{id:guid}")] + [HttpGet("{id:int}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Get(Guid routeId, Guid id, CancellationToken ct) => + public async Task Get(int routeId, int id, CancellationToken ct) => (await service.GetAsync(routeId, id, ct)).ToActionResult(); [HttpPost] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => + public async Task Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => (await service.CreateAsync(routeId, body, ct)).ToActionResult(); - [HttpPut("{id:guid}")] + [HttpPut("{id:int}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => + public async Task Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); - [HttpDelete("{id:guid}")] + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Delete(Guid routeId, Guid id, CancellationToken ct) => + public async Task Delete(int routeId, int id, CancellationToken ct) => (await service.DeleteAsync(routeId, id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 0569bf7..4a62d74 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -20,13 +20,13 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con [HttpGet] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => + public async Task List([FromQuery] int? requestTypeId, CancellationToken ct) => (await service.ListAsync(requestTypeId, ct)).ToActionResult(); - [HttpGet("{id:guid}")] + [HttpGet("{id:int}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Get(Guid id, CancellationToken ct) => + public async Task Get(int id, CancellationToken ct) => (await service.GetAsync(id, ct)).ToActionResult(); [HttpPost] @@ -37,17 +37,17 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) => (await service.CreateAsync(body, ct)).ToActionResult(); - [HttpPut("{id:guid}")] + [HttpPut("{id:int}")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] - public async Task Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => + public async Task Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); - [HttpDelete("{id:guid}")] + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Delete(Guid id, CancellationToken ct) => + public async Task Delete(int id, CancellationToken ct) => (await service.DeleteAsync(id, ct)).ToActionResult(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs index 424fdef..e9518c8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs @@ -2,7 +2,7 @@ using FluentResults; namespace Tnsu.ApprovalRoute.Application.Common.Errors; -public sealed class NotFoundError(string entity, Guid id) +public sealed class NotFoundError(string entity, object id) : Error($"{entity} с id {id} не найден."); public sealed class ValidationError(string message) : Error(message); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index fe74c5b..1a1b481 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -1,6 +1,6 @@ namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed record RequestTypeDto(Guid Id, string Name, string PageUrl); +public sealed record RequestTypeDto(int Id, string Name, string PageUrl); public sealed record CreateRequestTypeRequest(string Name, string PageUrl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 014b3a8..df914ee 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -18,7 +18,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok>(items); } - public async Task> GetAsync(Guid id, CancellationToken cancellationToken) + public async Task> GetAsync(int id, CancellationToken cancellationToken) { var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); @@ -36,7 +36,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(ToDto(entity)); } - public async Task> UpdateAsync(Guid id, UpdateRequestTypeRequest request, CancellationToken cancellationToken) + public async Task> UpdateAsync(int id, UpdateRequestTypeRequest request, CancellationToken cancellationToken) { var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) @@ -49,7 +49,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(ToDto(entity)); } - public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + public async Task DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 0a5f852..6966de9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -3,14 +3,14 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; public sealed record TemplateRouteStepDto( - Guid Id, + int Id, int OrderNo, string ExecutorEmail, string Position, ProjectRole ProjectRole, HeadType HeadType, RouteStepStatus Status, - Guid RouteId, + int RouteId, bool AllowReassignToOtherApprovers); public sealed record CreateTemplateRouteStepRequest( diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 0ba617a..244c35b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -9,7 +9,7 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) { - public async Task>> ListAsync(Guid routeId, CancellationToken cancellationToken) + public async Task>> ListAsync(int routeId, CancellationToken cancellationToken) { var items = await db.TemplateRouteSteps.AsNoTracking() .Where(x => x.RouteId == routeId) @@ -21,7 +21,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok>(items); } - public async Task> GetAsync(Guid routeId, Guid id, CancellationToken cancellationToken) + public async Task> GetAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.AsNoTracking() .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); @@ -31,7 +31,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) : Result.Ok(ToDto(entity)); } - public async Task> CreateAsync(Guid routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) + public async Task> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) { var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) @@ -60,7 +60,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) } public async Task> UpdateAsync( - Guid routeId, Guid id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) + int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) { var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) @@ -87,7 +87,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken); } - public async Task DeleteAsync(Guid routeId, Guid id, CancellationToken cancellationToken) + public async Task DeleteAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); if (entity is null) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 4868411..62dba0f 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -3,7 +3,7 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed record ResolvedRouteStepDto( - Guid Id, + int Id, int OrderNo, string ExecutorEmail, string Position, @@ -13,13 +13,13 @@ public sealed record ResolvedRouteStepDto( bool AllowReassignToOtherApprovers); public sealed record ResolveTemplateRouteResultDto( - Guid RouteId, + int RouteId, RouteType RouteType, bool RequiresControl, IReadOnlyList Steps); public sealed record ResolveTemplateRouteRequest( - Guid RequestTypeId, + int RequestTypeId, string? UserEmail, Guid? ProjectId, RouteStepStatus Status); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 34eef9d..ff964c4 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -3,9 +3,9 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed record TemplateRouteDto( - Guid Id, + int Id, RouteType RouteType, - Guid RequestTypeId, + int RequestTypeId, string? Department, string Company, bool ByProject, @@ -13,7 +13,7 @@ public sealed record TemplateRouteDto( public sealed record CreateTemplateRouteRequest( RouteType RouteType, - Guid RequestTypeId, + int RequestTypeId, string? Department, string Company, bool ByProject, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 89df532..24272d7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -14,7 +14,7 @@ public sealed class TemplateRouteService( ITnsuPortalClient portalClient, ICurrentUser currentUser) { - public async Task>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken) + public async Task>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) { var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); if (requestTypeId is { } id) @@ -28,7 +28,7 @@ public sealed class TemplateRouteService( return Result.Ok>(items); } - public async Task> GetAsync(Guid id, CancellationToken cancellationToken) + public async Task> GetAsync(int id, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); @@ -64,7 +64,7 @@ public sealed class TemplateRouteService( return Result.Ok(ToDto(entity)); } - public async Task> UpdateAsync(Guid id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken) + public async Task> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) @@ -86,7 +86,7 @@ public sealed class TemplateRouteService( return Result.Ok(ToDto(entity)); } - public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + public async Task DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (entity is null) @@ -113,7 +113,7 @@ public sealed class TemplateRouteService( var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); if (employee is null) return Result.Fail( - new NotFoundError("Сотрудник по email в справочнике api.tnsu.kz не найден", Guid.Empty)); + new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email)); var byProject = request.ProjectId is not null; var department = Normalize(employee.Department); @@ -154,7 +154,7 @@ public sealed class TemplateRouteService( } private Task HasDuplicateAsync( - Guid requestTypeId, string? department, string company, bool byProject, Guid? excludeId, CancellationToken cancellationToken) => + int requestTypeId, string? department, string company, bool byProject, int? excludeId, CancellationToken cancellationToken) => db.TemplateRoutes.AnyAsync(x => x.Id != excludeId && x.RequestTypeId == requestTypeId && diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index df43cbb..d86af83 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -1,11 +1,8 @@ -using System; -using System.Collections.Generic; - namespace Tnsu.ApprovalRoute.Domain.Entities; public class RequestType { - public Guid Id { get; set; } = Guid.NewGuid(); + public int Id { get; set; } public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index 9c0a675..a691c75 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -4,9 +4,9 @@ namespace Tnsu.ApprovalRoute.Domain.Entities; public class TemplateRoute { - public Guid Id { get; set; } = Guid.NewGuid(); + public int Id { get; set; } public RouteType RouteType { get; set; } = RouteType.Sequential; - public Guid RequestTypeId { get; set; } + public int RequestTypeId { get; set; } // Компания и отдел инициатора — названия из справочника AD (api-tnsu-portal /Employee/ByEmail), // хранятся строками без FK в этой БД. Отдел опционален, компания обязательна. diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index b9590b4..9eff50c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -4,7 +4,7 @@ namespace Tnsu.ApprovalRoute.Domain.Entities; public class TemplateRouteStep { - public Guid Id { get; set; } = Guid.NewGuid(); + public int Id { get; set; } public int OrderNo { get; set; } // Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. @@ -14,7 +14,7 @@ public class TemplateRouteStep public HeadType HeadType { get; set; } = HeadType.None; public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; - public Guid RouteId { get; set; } + public int RouteId { get; set; } public bool AllowReassignToOtherApprovers { get; set; } public TemplateRoute? Route { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.Designer.cs similarity index 89% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.Designer.cs index a472fbc..ff4bd31 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.Designer.cs @@ -1,5 +1,4 @@ // -using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -12,7 +11,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260724071302_InitialCreate")] + [Migration("20260724085143_InitialCreate")] partial class InitialCreate { /// @@ -27,11 +26,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -52,11 +53,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ByProject") .HasColumnType("boolean") .HasColumnName("by_project"); @@ -72,8 +75,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("department"); - b.Property("RequestTypeId") - .HasColumnType("uuid") + b.Property("RequestTypeId") + .HasColumnType("integer") .HasColumnName("request_type_id"); b.Property("RequiresControl") @@ -98,11 +101,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AllowReassignToOtherApprovers") .HasColumnType("boolean") .HasColumnName("allow_reassign_to_other_approvers"); @@ -135,8 +140,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("project_role"); - b.Property("RouteId") - .HasColumnType("uuid") + b.Property("RouteId") + .HasColumnType("integer") .HasColumnName("route_id"); b.Property("Status") diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.cs similarity index 82% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.cs index 8645c1f..413382c 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724071302_InitialCreate.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.cs @@ -1,5 +1,5 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -15,7 +15,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations name: "request_types", columns: table => new { - id = table.Column(type: "uuid", nullable: false), + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), page_url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false) }, @@ -28,9 +29,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations name: "template_routes", columns: table => new { - id = table.Column(type: "uuid", nullable: false), + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), route_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - request_type_id = table.Column(type: "uuid", nullable: false), + request_type_id = table.Column(type: "integer", nullable: false), department = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), company = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), by_project = table.Column(type: "boolean", nullable: false), @@ -51,14 +53,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations name: "template_route_steps", columns: table => new { - id = table.Column(type: "uuid", nullable: false), + 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), status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), - route_id = table.Column(type: "uuid", nullable: false), + route_id = table.Column(type: "integer", nullable: false), allow_reassign_to_other_approvers = table.Column(type: "boolean", nullable: false) }, constraints: table => diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 73d526e..1043050 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -1,5 +1,4 @@ // -using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -24,11 +23,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -49,11 +50,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ByProject") .HasColumnType("boolean") .HasColumnName("by_project"); @@ -69,8 +72,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("department"); - b.Property("RequestTypeId") - .HasColumnType("uuid") + b.Property("RequestTypeId") + .HasColumnType("integer") .HasColumnName("request_type_id"); b.Property("RequiresControl") @@ -95,11 +98,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => { - b.Property("Id") + b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("uuid") + .HasColumnType("integer") .HasColumnName("id"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AllowReassignToOtherApprovers") .HasColumnType("boolean") .HasColumnName("allow_reassign_to_other_approvers"); @@ -132,8 +137,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("project_role"); - b.Property("RouteId") - .HasColumnType("uuid") + b.Property("RouteId") + .HasColumnType("integer") .HasColumnName("route_id"); b.Property("Status") diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index 83f8d38..a14b282 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -33,7 +33,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task GetAsync_UnknownId_ReturnsFailedResult() { // Action - var result = await _service.GetAsync(Guid.NewGuid(), CancellationToken.None); + var result = await _service.GetAsync(999999, CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.UpdateAsync( - Guid.NewGuid(), new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); + 999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 59879e5..ce63527 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -34,7 +34,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); } - private async Task CreateRequestTypeAsync(string name) => + private async Task CreateRequestTypeAsync(string name) => (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] @@ -62,7 +62,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); } - private async Task CreateRouteAsync(string requestTypeName, bool byProject) + private async Task CreateRouteAsync(string requestTypeName, bool byProject) { var requestTypeId = (await _requestTypeService.CreateAsync( new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; @@ -41,7 +41,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture Date: Fri, 24 Jul 2026 14:05:37 +0500 Subject: [PATCH 29/38] =?UTF-8?q?warnings=20=D0=B8=D0=B7=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BB=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Common/ApiResponse.cs | 2 +- .../Common/ResultExtensions.cs | 3 +- .../Common/Errors/ApiErrors.cs | 2 -- .../TemplateRouteStepDto.cs | 3 +- .../TemplateRouteStepService.cs | 28 +++++++++---------- .../TemplateRouteStepServiceTests.cs | 5 ++-- 6 files changed, 21 insertions(+), 22 deletions(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs index 36f539e..48561fe 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs @@ -1,5 +1,5 @@ namespace Tnsu.ApprovalRoute.Api.Common; -public sealed record ApiResponse(T Data, IReadOnlyList Warnings); +public sealed record ApiResponse(T Data); public sealed record ApiError(string Message); diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs index 26c7bef..12ac8bb 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs @@ -11,8 +11,7 @@ public static class ResultExtensions if (result.IsFailed) return result.Errors[0].ToActionResult(); - var warnings = result.Reasons.OfType().Select(w => w.Message).ToList(); - return new OkObjectResult(new ApiResponse(result.Value, warnings)); + return new OkObjectResult(new ApiResponse(result.Value)); } public static IActionResult ToActionResult(this Result result) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs index e9518c8..4285651 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs @@ -8,5 +8,3 @@ public sealed class NotFoundError(string entity, object id) public sealed class ValidationError(string message) : Error(message); public sealed class ConflictError(string message) : Error(message); - -public sealed class Warning(string message) : Success(message); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 6966de9..d24d5f0 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -11,7 +11,8 @@ public sealed record TemplateRouteStepDto( HeadType HeadType, RouteStepStatus Status, int RouteId, - bool AllowReassignToOtherApprovers); + bool AllowReassignToOtherApprovers, + string? Warning = null); public sealed record CreateTemplateRouteStepRequest( int OrderNo, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 244c35b..9e1f094 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -55,8 +55,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); - var result = Result.Ok(ToDto(entity)); - return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken); + return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } public async Task> UpdateAsync( @@ -83,8 +82,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; await db.SaveChangesAsync(cancellationToken); - var result = Result.Ok(ToDto(entity)); - return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken); + return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } public async Task DeleteAsync(int routeId, int id, CancellationToken cancellationToken) @@ -99,18 +97,20 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(); } - private async Task> AppendMissingProjectRoleWarningAsync( - TemplateRoute route, Result result, CancellationToken cancellationToken) + // Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning. + private async Task ToDtoWithWarningAsync( + TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) { - if (!route.ByProject) - return result; + string? warning = null; + if (route.ByProject) + { + var hasProjectRoleStep = await db.TemplateRouteSteps + .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken); + if (!hasProjectRoleStep) + warning = "Должен быть хотя бы один шаг с ролью в проекте."; + } - var hasProjectRoleStep = await db.TemplateRouteSteps - .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken); - - return hasProjectRoleStep - ? result - : result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте.")); + return ToDto(entity) with { Warning = warning }; } private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 16c56ee..a090faa 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -99,7 +99,8 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture r.Message.Contains("ролью в проекте")); + created.Value.Warning.ShouldNotBeNull(); + created.Value.Warning.ShouldContain("ролью в проекте"); } [Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")] @@ -116,7 +117,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture Date: Fri, 24 Jul 2026 14:18:16 +0500 Subject: [PATCH 30/38] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=81=D1=81=D0=B8=D0=B2=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs | 2 +- .../src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs | 8 ++++---- .../Filters/ValidationActionFilter.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs index 48561fe..4a9a560 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs @@ -2,4 +2,4 @@ namespace Tnsu.ApprovalRoute.Api.Common; public sealed record ApiResponse(T Data); -public sealed record ApiError(string Message); +public sealed record ApiError(IReadOnlyList Messages); diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs index 12ac8bb..9935ac3 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs @@ -24,9 +24,9 @@ public static class ResultExtensions private static IActionResult ToActionResult(this IError error) => error switch { - NotFoundError => new NotFoundObjectResult(new ApiError(error.Message)), - ValidationError => new BadRequestObjectResult(new ApiError(error.Message)), - ConflictError => new ConflictObjectResult(new ApiError(error.Message)), - _ => new ObjectResult(new ApiError(error.Message)) { StatusCode = StatusCodes.Status500InternalServerError } + NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])), + ValidationError => new BadRequestObjectResult(new ApiError([error.Message])), + ConflictError => new ConflictObjectResult(new ApiError([error.Message])), + _ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError } }; } diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs index 7e252cc..f6cb0b3 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs @@ -25,7 +25,7 @@ public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : I continue; context.Result = new BadRequestObjectResult( - new ApiError(string.Join(" ", result.Errors.Select(e => e.ErrorMessage)))); + new ApiError(result.Errors.Select(e => e.ErrorMessage).ToList())); return; } -- 2.34.1 From 40e79742445ccaa3fa0d794ad57910d111d874c0 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 14:27:21 +0500 Subject: [PATCH 31/38] DirectorTansu --- backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs index 8643b83..a3e097d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -4,5 +4,6 @@ public enum HeadType { None, Chief, - DirectionHead + DirectionHead, + DirectorTansu } -- 2.34.1 From a125ed2834a2f2a6e66e830030d5539b96ac84a3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 15:33:54 +0500 Subject: [PATCH 32/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tnsu.ApprovalRoute.Api/appsettings.json | 4 +-- .../Tnsu.ApprovalRoute.Infrastructure.csproj | 2 ++ .../TnsuPortal/HttpTnsuPortalClient.cs | 27 ++++++++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index 3205e20..bb23562 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -9,8 +9,8 @@ "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" }, "Entra": { - "TenantId": "", - "Audience": "" + "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", + "Audience": "9232d9e2-9807-4753-808f-07250e5474b0" }, "Dev": { "DefaultUserEmail": "dev@tnsu.kz" diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index 5ea6939..33b62bc 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -13,5 +13,7 @@ + + diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 621693d..d1b387e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -1,14 +1,23 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -// Клиент api.tnsu.kz (api-tnsu-portal). Сервисный токен прокидывается через TnsuPortalTokenProvider. -public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) : ITnsuPortalClient +// Клиент api.tnsu.kz (api-tnsu-portal). +public sealed class HttpTnsuPortalClient( + HttpClient httpClient, + TnsuPortalTokenProvider tokenProvider, + IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient { + // Разные эндпоинты api-tnsu-portal отдают JSON в разном регистре (Dapper-результаты — PascalCase, + // строго типизированные модели — camelCase), поэтому сопоставляем поля без учёта регистра. + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}"); @@ -19,7 +28,7 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP return null; response.EnsureSuccessStatusCode(); - var employee = await response.Content.ReadFromJsonAsync(cancellationToken); + var employee = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); if (employee is null) return null; @@ -36,7 +45,7 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP return []; response.EnsureSuccessStatusCode(); - var project = await response.Content.ReadFromJsonAsync(cancellationToken); + var project = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); if (project is null) return []; @@ -56,8 +65,18 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP executors.Add(new ProjectExecutorInfo(role, email)); } + // ВРЕМЕННО: для TnsuPortal ещё нет своего client-credentials приложения в Entra, поэтому прокидываем + // токен текущего пользователя дальше — его aud совпадает с ClientId api-tnsu-portal, так что подходит. + // Когда появится своё приложение — вернуть на tokenProvider.GetAccessTokenAsync(...). private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); + if (!string.IsNullOrWhiteSpace(incoming)) + { + request.Headers.TryAddWithoutValidation("Authorization", incoming); + return; + } + var token = await tokenProvider.GetAccessTokenAsync(cancellationToken); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } -- 2.34.1 From 3ae1965c293aca5f21ff9e1dd430770ad3816b73 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 16:53:35 +0500 Subject: [PATCH 33/38] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=88=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D0=B3=D1=83=D0=B8=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 1 - .../TemplateRoutes/TemplateRouteDto.cs | 12 +++---- .../TemplateRoutes/TemplateRouteService.cs | 35 ++++++++----------- .../TemplateRoutes/TemplateRouteValidators.cs | 4 +-- .../Entities/TemplateRoute.cs | 8 ++--- ...frastructureServiceCollectionExtensions.cs | 2 ++ .../Persistence/ApprovalRouteDbContext.cs | 4 +-- ... 20260724114550_InitialCreate.Designer.cs} | 22 ++++++------ ...ate.cs => 20260724114550_InitialCreate.cs} | 11 +++--- .../ApprovalRouteDbContextModelSnapshot.cs | 20 +++++------ .../TnsuPortal/HttpTnsuPortalClient.cs | 6 ++-- .../TnsuPortal/TnsuPortalDtos.cs | 2 +- .../TemplateRouteServiceTests.cs | 26 +++++++------- .../TemplateRouteStepServiceTests.cs | 4 +-- 14 files changed, 74 insertions(+), 83 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260724085143_InitialCreate.Designer.cs => 20260724114550_InitialCreate.Designer.cs} (91%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260724085143_InitialCreate.cs => 20260724114550_InitialCreate.cs} (92%) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index d3200eb..dccf8c5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -16,7 +16,6 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.AddApprovalRouteAuth(builder.Configuration); -builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddOpenApi(options => { diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index ff964c4..1b1bd1b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -6,22 +6,22 @@ public sealed record TemplateRouteDto( int Id, RouteType RouteType, int RequestTypeId, - string? Department, - string Company, + Guid? DepartmentId, + Guid CompanyId, bool ByProject, bool RequiresControl); public sealed record CreateTemplateRouteRequest( RouteType RouteType, int RequestTypeId, - string? Department, - string Company, + Guid? DepartmentId, + Guid CompanyId, bool ByProject, bool RequiresControl); public sealed record UpdateTemplateRouteRequest( RouteType RouteType, - string? Department, - string Company, + Guid? DepartmentId, + Guid CompanyId, bool ByProject, bool RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 24272d7..94167ca 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -22,7 +22,7 @@ public sealed class TemplateRouteService( var items = await query .Select(x => new TemplateRouteDto( - x.Id, x.RouteType, x.RequestTypeId, x.Department, x.Company, x.ByProject, x.RequiresControl)) + x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -43,8 +43,7 @@ public sealed class TemplateRouteService( if (!requestTypeExists) return Result.Fail(new NotFoundError("Тип заявки", r.RequestTypeId)); - var department = Normalize(r.Department); - var duplicate = await HasDuplicateAsync(r.RequestTypeId, department, r.Company, r.ByProject, excludeId: null, cancellationToken); + var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); @@ -53,8 +52,8 @@ public sealed class TemplateRouteService( { RouteType = r.RouteType, RequestTypeId = r.RequestTypeId, - Department = department, - Company = r.Company, + DepartmentId = r.DepartmentId, + CompanyId = r.CompanyId, ByProject = r.ByProject, RequiresControl = r.RequiresControl }; @@ -70,15 +69,14 @@ public sealed class TemplateRouteService( if (entity is null) return Result.Fail(new NotFoundError("Маршрут", id)); - var department = Normalize(r.Department); - var duplicate = await HasDuplicateAsync(entity.RequestTypeId, department, r.Company, r.ByProject, excludeId: entity.Id, cancellationToken); + var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken); if (duplicate) return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); entity.RouteType = r.RouteType; - entity.Department = department; - entity.Company = r.Company; + entity.DepartmentId = r.DepartmentId; + entity.CompanyId = r.CompanyId; entity.ByProject = r.ByProject; entity.RequiresControl = r.RequiresControl; await db.SaveChangesAsync(cancellationToken); @@ -116,23 +114,22 @@ public sealed class TemplateRouteService( new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email)); var byProject = request.ProjectId is not null; - var department = Normalize(employee.Department); var route = await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => x.RequestTypeId == request.RequestTypeId && - x.Company == employee.Company && + x.CompanyId == employee.CompanyId && x.ByProject == byProject && - x.Department == department) + x.DepartmentId == employee.DepartmentId) .FirstOrDefaultAsync(cancellationToken) ?? await db.TemplateRoutes.AsNoTracking() .Include(x => x.Steps) .Where(x => x.RequestTypeId == request.RequestTypeId && - x.Company == employee.Company && + x.CompanyId == employee.CompanyId && x.ByProject == byProject && - x.Department == null) + x.DepartmentId == null) .FirstOrDefaultAsync(cancellationToken); if (route is null) @@ -154,12 +151,12 @@ public sealed class TemplateRouteService( } private Task HasDuplicateAsync( - int requestTypeId, string? department, string company, bool byProject, int? excludeId, CancellationToken cancellationToken) => + int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) => db.TemplateRoutes.AnyAsync(x => x.Id != excludeId && x.RequestTypeId == requestTypeId && - x.Department == department && - x.Company == company && + x.DepartmentId == departmentId && + x.CompanyId == companyId && x.ByProject == byProject, cancellationToken); @@ -181,8 +178,6 @@ public sealed class TemplateRouteService( step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); } - private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; - private static TemplateRouteDto ToDto(TemplateRoute e) => new( - e.Id, e.RouteType, e.RequestTypeId, e.Department, e.Company, e.ByProject, e.RequiresControl); + e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 01a22a9..0088486 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -7,7 +7,7 @@ public sealed class CreateTemplateRouteRequestValidator : AbstractValidator x.Company).NotEmpty().WithMessage("Компания обязательна."); + RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); } } @@ -15,7 +15,7 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator x.Company).NotEmpty().WithMessage("Компания обязательна."); + RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); } } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index a691c75..bf50e4d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -8,10 +8,10 @@ public class TemplateRoute public RouteType RouteType { get; set; } = RouteType.Sequential; public int RequestTypeId { get; set; } - // Компания и отдел инициатора — названия из справочника AD (api-tnsu-portal /Employee/ByEmail), - // хранятся строками без FK в этой БД. Отдел опционален, компания обязательна. - public string? Department { get; set; } - public string Company { get; set; } = string.Empty; + // Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД. + // Отдел опционален, компания обязательна. + public Guid? DepartmentId { get; set; } + public Guid CompanyId { get; set; } public bool ByProject { get; set; } public bool RequiresControl { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs index cdef65a..fccf5ce 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -13,6 +13,8 @@ public static class InfrastructureServiceCollectionExtensions public static IServiceCollection AddApprovalRouteInfrastructure( this IServiceCollection services, IConfiguration configuration) { + services.AddHttpContextAccessor(); + services.AddDbContext(opt => opt.UseNpgsql(configuration.GetConnectionString("Postgres")) .UseSnakeCaseNamingConvention()); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 2721d86..a746779 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -22,10 +22,8 @@ public class ApprovalRouteDbContext(DbContextOptions opt { e.HasKey(x => x.Id); e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); - e.Property(x => x.Company).HasMaxLength(256).IsRequired(); - e.Property(x => x.Department).HasMaxLength(256); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); - e.HasIndex(x => new { x.RequestTypeId, x.Department, x.Company, x.ByProject }).IsUnique(); + e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique(); }); modelBuilder.Entity(e => diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.Designer.cs similarity index 91% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.Designer.cs index ff4bd31..3c494dd 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.Designer.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -11,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260724085143_InitialCreate")] + [Migration("20260724114550_InitialCreate")] partial class InitialCreate { /// @@ -64,16 +65,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("by_project"); - b.Property("Company") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("company"); + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); - b.Property("Department") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("department"); + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); b.Property("RequestTypeId") .HasColumnType("integer") @@ -92,9 +90,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId", "Department", "Company", "ByProject") + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") .IsUnique() - .HasDatabaseName("ix_template_routes_request_type_id_department_company_by_proje"); + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); b.ToTable("template_routes", (string)null); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.cs similarity index 92% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.cs index 413382c..b442f61 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724085143_InitialCreate.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260724114550_InitialCreate.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable @@ -33,8 +34,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), route_type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), request_type_id = table.Column(type: "integer", nullable: false), - department = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - company = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + department_id = table.Column(type: "uuid", nullable: true), + company_id = table.Column(type: "uuid", nullable: false), by_project = table.Column(type: "boolean", nullable: false), requires_control = table.Column(type: "boolean", nullable: false) }, @@ -81,9 +82,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations columns: new[] { "route_id", "order_no" }); migrationBuilder.CreateIndex( - name: "ix_template_routes_request_type_id_department_company_by_proje", + name: "ix_template_routes_request_type_id_department_id_company_id_by", table: "template_routes", - columns: new[] { "request_type_id", "department", "company", "by_project" }, + columns: new[] { "request_type_id", "department_id", "company_id", "by_project" }, unique: true); } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 1043050..5eaa7cb 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -61,16 +62,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("by_project"); - b.Property("Company") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("company"); + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); - b.Property("Department") - .HasMaxLength(256) - .HasColumnType("character varying(256)") - .HasColumnName("department"); + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); b.Property("RequestTypeId") .HasColumnType("integer") @@ -89,9 +87,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_template_routes"); - b.HasIndex("RequestTypeId", "Department", "Company", "ByProject") + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") .IsUnique() - .HasDatabaseName("ix_template_routes_request_type_id_department_company_by_proje"); + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); b.ToTable("template_routes", (string)null); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index d1b387e..81b0848 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -32,7 +32,7 @@ public sealed class HttpTnsuPortalClient( if (employee is null) return null; - return new EmployeeDirectoryInfo(employee.Company ?? string.Empty, employee.Section ?? string.Empty); + return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId); } public async Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) @@ -82,8 +82,8 @@ public sealed class HttpTnsuPortalClient( } private sealed record EmployeeResponse( - [property: JsonPropertyName("company")] string? Company, - [property: JsonPropertyName("section")] string? Section); + [property: JsonPropertyName("companyId")] Guid? CompanyId, + [property: JsonPropertyName("departmentId")] Guid? DepartmentId); private sealed record ProjectResponse( [property: JsonPropertyName("projectManager")] string? ProjectManager, diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 909cf2a..0abb76b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -public sealed record EmployeeDirectoryInfo(string Company, string Department); +public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index ce63527..1c49452 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -12,8 +12,8 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; public sealed class TemplateRouteServiceTests : IClassFixture { - private const string Company = "ТОО TANSU Construction"; - private const string Department = "IT"; + private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); + private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); private readonly ServiceProvider _services; private readonly TemplateRouteService _service; @@ -24,7 +24,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(Company, Department)); + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId)); portalClient .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); @@ -45,7 +45,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -158,7 +158,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); @@ -184,7 +184,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture { - private const string Company = "ТОО TANSU Construction"; + private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); private readonly TemplateRouteStepService _service; private readonly TemplateRouteService _routeService; @@ -30,7 +30,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture Date: Fri, 24 Jul 2026 17:54:34 +0500 Subject: [PATCH 34/38] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRouteStepDto.cs | 8 ++-- .../TemplateRouteStepService.cs | 39 +++++++++++++++++-- .../TemplateRouteStepValidators.cs | 16 ++++++-- .../TemplateRouteStepServiceTests.cs | 37 ++++++++++++++++++ 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index d24d5f0..73eef71 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -16,8 +16,8 @@ public sealed record TemplateRouteStepDto( public sealed record CreateTemplateRouteStepRequest( int OrderNo, - string ExecutorEmail, - string Position, + string? ExecutorEmail, + string? Position, ProjectRole ProjectRole, HeadType HeadType, RouteStepStatus Status, @@ -25,8 +25,8 @@ public sealed record CreateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest( int OrderNo, - string ExecutorEmail, - string Position, + string? ExecutorEmail, + string? Position, ProjectRole ProjectRole, HeadType HeadType, RouteStepStatus Status, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 9e1f094..2bc5267 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -41,12 +41,21 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) 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); + if (duplicate) + return Result.Fail(new ConflictError( + "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + var entity = new TemplateRouteStep { RouteId = route.Id, OrderNo = r.OrderNo, - ExecutorEmail = r.ExecutorEmail, - Position = r.Position, + ExecutorEmail = executorEmail, + Position = position, ProjectRole = r.ProjectRole, HeadType = r.HeadType, Status = r.Status, @@ -73,9 +82,18 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) 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); + if (duplicate) + return Result.Fail(new ConflictError( + "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + entity.OrderNo = r.OrderNo; - entity.ExecutorEmail = r.ExecutorEmail; - entity.Position = r.Position; + entity.ExecutorEmail = executorEmail; + entity.Position = position; entity.ProjectRole = r.ProjectRole; entity.HeadType = r.HeadType; entity.Status = r.Status; @@ -97,6 +115,19 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(); } + private Task 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. private async Task ToDtoWithWarningAsync( TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index be1d48b..8f5e7a7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -8,8 +8,12 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< public CreateTemplateRouteStepRequestValidator() { RuleFor(x => x) - .Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) - .WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + .Must(x => + !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() { RuleFor(x => x) - .Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) - .WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); + .Must(x => + !string.IsNullOrWhiteSpace(x.ExecutorEmail) || + !string.IsNullOrWhiteSpace(x.Position) || + x.ProjectRole != ProjectRole.None || + x.HeadType != HeadType.None) + .WithMessage("Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); } } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index 2e4b4dc..12f6f0c 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -65,6 +65,43 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture Date: Mon, 27 Jul 2026 09:15:28 +0500 Subject: [PATCH 35/38] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20patch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/DevAuthHandler.cs | 1 - .../Controllers/RequestTypesController.cs | 7 +++ .../TemplateRouteStepsController.cs | 8 ++++ .../Controllers/TemplateRoutesController.cs | 8 ++++ .../Filters/ValidationActionFilter.cs | 1 - backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 3 -- .../RequestTypes/RequestTypeDto.cs | 2 + .../RequestTypes/RequestTypeService.cs | 13 ++++++ .../RequestTypes/RequestTypeValidators.cs | 11 +++++ .../TemplateRouteStepDto.cs | 9 ++++ .../TemplateRouteStepService.cs | 45 ++++++++++++++++++- .../TemplateRoutes/TemplateRouteDto.cs | 7 +++ .../TemplateRoutes/TemplateRouteService.cs | 27 ++++++++++- .../TemplateRoutes/TemplateRouteValidators.cs | 9 ++++ .../Entities/TemplateRoute.cs | 2 - .../Entities/TemplateRouteStep.cs | 1 - .../Enums/ProjectRole.cs | 2 - .../TnsuPortal/HttpTnsuPortalClient.cs | 7 --- .../TnsuPortal/TnsuPortalTokenProvider.cs | 1 - .../RequestTypeServiceTests.cs | 27 +++++++++++ .../TemplateRouteServiceTests.cs | 23 ++++++++++ .../TemplateRouteStepServiceTests.cs | 24 ++++++++++ .../Infrastructure/PostgresDatabaseFixture.cs | 2 - .../TestServiceProviderFactory.cs | 2 - 24 files changed, 217 insertions(+), 25 deletions(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs index ed829eb..8086a82 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Api.Auth; -// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID). public sealed class DevAuthHandler( IOptionsMonitor options, ILoggerFactory logger, diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index 4cef00c..65bb312 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -35,6 +35,13 @@ public sealed class RequestTypesController(RequestTypeService service) : Control public async Task Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); + [HttpPatch("{id:int}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) => + (await service.PatchAsync(id, body, ct)).ToActionResult(); + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs index a7e6771..0f74e73 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -36,6 +36,14 @@ public sealed class TemplateRouteStepsController(TemplateRouteStepService servic public async Task Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); + [HttpPatch("{id:int}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] + public async Task Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) => + (await service.PatchAsync(routeId, id, body, ct)).ToActionResult(); + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 4a62d74..8b38e22 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -45,6 +45,14 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con public async Task Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); + [HttpPatch("{id:int}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] + public async Task Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) => + (await service.PatchAsync(id, body, ct)).ToActionResult(); + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs index f6cb0b3..d447132 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs @@ -5,7 +5,6 @@ using Tnsu.ApprovalRoute.Api.Common; namespace Tnsu.ApprovalRoute.Api.Filters; -// Валидирует аргументы action'ов, для которых зарегистрирован IValidator — без валидации внутри сервисов. public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter { public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index dccf8c5..2e6474d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -47,7 +47,6 @@ builder.Services.AddOpenApi(options => builder.Services.AddHealthChecks(); builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); -// Тот же конвертер для Http.Json — их читает генератор OpenAPI, чтобы enum в схеме были строками, а не числами. builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); @@ -56,8 +55,6 @@ if (app.Environment.IsDevelopment()) { app.MapOpenApi(); - // Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security) - // и корректно прикрепляет Bearer-токен к запросам. app.MapScalarApiReference(options => { options diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index 1a1b481..50cdce7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -5,3 +5,5 @@ public sealed record RequestTypeDto(int Id, string Name, string PageUrl); public sealed record CreateRequestTypeRequest(string Name, string PageUrl); public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); + +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index df914ee..481c5d9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -49,6 +49,19 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(ToDto(entity)); } + public async Task> PatchAsync(int id, PatchRequestTypeRequest request, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Тип заявки", id)); + + entity.Name = request.Name ?? entity.Name; + entity.PageUrl = request.PageUrl ?? entity.PageUrl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + public async Task DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index cfd5bf4..51946db 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -19,3 +19,14 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); } } + +public sealed class PatchRequestTypeRequestValidator : AbstractValidator +{ + public PatchRequestTypeRequestValidator() + { + When(x => x.Name is not null, () => + RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым.")); + When(x => x.PageUrl is not null, () => + RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 73eef71..c28cf0e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -31,3 +31,12 @@ public sealed record UpdateTemplateRouteStepRequest( HeadType HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers); + +public sealed record PatchTemplateRouteStepRequest( + int? OrderNo, + string? ExecutorEmail, + string? Position, + 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 2bc5267..be18cb8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -103,6 +103,50 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } + public async Task> PatchAsync( + int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + if (route is null) + return Result.Fail(new NotFoundError("Маршрут", routeId)); + + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Шаг маршрута", id)); + + var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail; + var position = r.Position ?? entity.Position; + var projectRole = r.ProjectRole ?? entity.ProjectRole; + var headType = r.HeadType ?? entity.HeadType; + var status = r.Status ?? entity.Status; + + if (!route.ByProject && projectRole != ProjectRole.None) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + + if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && + projectRole == ProjectRole.None && headType == HeadType.None) + return Result.Fail(new ValidationError( + "Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); + + var duplicate = await HasDuplicateAsync( + route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + + entity.OrderNo = r.OrderNo ?? entity.OrderNo; + entity.ExecutorEmail = executorEmail; + entity.Position = position; + entity.ProjectRole = projectRole; + entity.HeadType = headType; + entity.Status = status; + entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); + } + public async Task DeleteAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); @@ -128,7 +172,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) x.Status == status, cancellationToken); - // Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning. private async Task ToDtoWithWarningAsync( TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) { diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 1b1bd1b..6992701 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -25,3 +25,10 @@ public sealed record UpdateTemplateRouteRequest( Guid CompanyId, bool ByProject, bool RequiresControl); + +public sealed record PatchTemplateRouteRequest( + RouteType? RouteType, + Guid? DepartmentId, + Guid? CompanyId, + bool? ByProject, + bool? RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 94167ca..1ca4a9a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -84,6 +84,31 @@ public sealed class TemplateRouteService( return Result.Ok(ToDto(entity)); } + public async Task> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", id)); + + var departmentId = r.DepartmentId ?? entity.DepartmentId; + var companyId = r.CompanyId ?? entity.CompanyId; + var byProject = r.ByProject ?? entity.ByProject; + + var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(new ConflictError( + "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); + + entity.RouteType = r.RouteType ?? entity.RouteType; + entity.DepartmentId = departmentId; + entity.CompanyId = companyId; + entity.ByProject = byProject; + entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl; + await db.SaveChangesAsync(cancellationToken); + + return Result.Ok(ToDto(entity)); + } + public async Task DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); @@ -162,8 +187,6 @@ public sealed class TemplateRouteService( private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) { - // Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz); - // должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона. if (step.ProjectRole != ProjectRole.None) { var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 0088486..23019e4 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -19,6 +19,15 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator +{ + public PatchTemplateRouteRequestValidator() + { + When(x => x.CompanyId is not null, () => + RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна.")); + } +} + public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator { public ResolveTemplateRouteRequestValidator() diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index bf50e4d..9319917 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -8,8 +8,6 @@ public class TemplateRoute public RouteType RouteType { get; set; } = RouteType.Sequential; public int RequestTypeId { get; set; } - // Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД. - // Отдел опционален, компания обязательна. public Guid? DepartmentId { get; set; } public Guid CompanyId { get; set; } public bool ByProject { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 9eff50c..75305c9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -7,7 +7,6 @@ public class TemplateRouteStep public int Id { get; set; } public int OrderNo { get; set; } - // Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. public string ExecutorEmail { get; set; } = string.Empty; public string Position { get; set; } = string.Empty; public ProjectRole ProjectRole { get; set; } = ProjectRole.None; diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs index a9c7afd..3d4bfcd 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs @@ -1,7 +1,5 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; -// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal -// (project_manager / planner / manager_hse / security). public enum ProjectRole { None, diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 81b0848..17b73e1 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -8,14 +8,11 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -// Клиент api.tnsu.kz (api-tnsu-portal). public sealed class HttpTnsuPortalClient( HttpClient httpClient, TnsuPortalTokenProvider tokenProvider, IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient { - // Разные эндпоинты api-tnsu-portal отдают JSON в разном регистре (Dapper-результаты — PascalCase, - // строго типизированные модели — camelCase), поэтому сопоставляем поля без учёта регистра. private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) @@ -49,7 +46,6 @@ public sealed class HttpTnsuPortalClient( if (project is null) return []; - // Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security). var executors = new List(); AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager); AddIfPresent(executors, ProjectRole.Planner, project.Planner); @@ -65,9 +61,6 @@ public sealed class HttpTnsuPortalClient( executors.Add(new ProjectExecutorInfo(role, email)); } - // ВРЕМЕННО: для TnsuPortal ещё нет своего client-credentials приложения в Entra, поэтому прокидываем - // токен текущего пользователя дальше — его aud совпадает с ClientId api-tnsu-portal, так что подходит. - // Когда появится своё приложение — вернуть на tokenProvider.GetAccessTokenAsync(...). private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs index 6f7dfd4..2d5dcac 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя. public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions options) { private string? _cachedToken; diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index a14b282..30ff46f 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -66,6 +66,33 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I result.IsFailed.ShouldBeTrue(); } + [Fact(DisplayName = "Патч типа заявки меняет только переданное поле, остальные остаются прежними")] + public async Task PatchAsync_UpdatesOnlyProvidedFields() + { + // Arrange + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None); + + // Action + var patched = await _service.PatchAsync( + created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None); + + // Assert + patched.IsSuccess.ShouldBeTrue(); + patched.Value.Name.ShouldBe("Patched name"); + patched.Value.PageUrl.ShouldBe("/original"); + } + + [Fact(DisplayName = "Патч несуществующего типа заявки возвращает неуспешный результат")] + public async Task PatchAsync_UnknownId_ReturnsFailedResult() + { + // Action + var result = await _service.PatchAsync( + 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + [Fact(DisplayName = "Удаление типа заявки убирает его из базы")] public async Task DeleteAsync_RemovesEntity() { diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 1c49452..165947b 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -106,6 +106,29 @@ public sealed class TemplateRouteServiceTests : IClassFixture Date: Mon, 27 Jul 2026 15:44:25 +0500 Subject: [PATCH 36/38] =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20?= =?UTF-8?q?=D0=BF=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 Date: Mon, 27 Jul 2026 17:03:25 +0500 Subject: [PATCH 37/38] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tnsu.ApprovalRoute.Api/appsettings.json | 3 +- .../Common/Errors/{ApiErrors.cs => Errors.cs} | 0 .../TemplateRoutes/ResolveTemplateRouteDto.cs | 3 - .../TemplateRoutes/TemplateRouteService.cs | 48 +++++-- .../TnsuPortal/HttpTnsuPortalClient.cs | 60 ++++++--- .../TnsuPortal/ITnsuPortalClient.cs | 4 + .../TnsuPortal/TnsuPortalDtos.cs | 2 +- .../TnsuPortal/TnsuPortalOptions.cs | 1 + .../TemplateRouteServiceTests.cs | 124 +++++++++++++++++- 9 files changed, 203 insertions(+), 42 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/{ApiErrors.cs => Errors.cs} (100%) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index bb23562..ebc9871 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -20,6 +20,7 @@ "TenantId": "", "ClientId": "", "ClientSecret": "", - "Scope": "" + "Scope": "", + "DirectorTansuEmail": "director.tansu@tnsu.kz" } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs similarity index 100% rename from backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs rename to backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/Errors.cs diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index eb9ac43..7947af8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -7,9 +7,6 @@ public sealed record ResolvedRouteStepDto( int OrderNo, string? ExecutorEmail, string? Position, - ProjectRole? ProjectRole, - HeadType? HeadType, - RouteStepStatus Status, bool AllowReassignToOtherApprovers); public sealed record ResolveTemplateRouteResultDto( diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index b0e4e44..60a56e9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -1,5 +1,6 @@ using FluentResults; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Domain.Entities; @@ -12,7 +13,8 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed class TemplateRouteService( ApprovalRouteDbContext db, ITnsuPortalClient portalClient, - ICurrentUser currentUser) + ICurrentUser currentUser, + IOptions portalOptions) { public async Task>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) { @@ -166,12 +168,15 @@ public sealed class TemplateRouteService( if (request.ProjectId is { } projectId) projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); - var steps = route.Steps + var matchingSteps = route.Steps .Where(s => s.Status == request.Status) .OrderBy(s => s.OrderNo) - .Select(s => ResolveStep(s, projectExecutors)) .ToList(); + var steps = new List(); + foreach (var step in matchingSteps) + steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken)); + return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps)); } @@ -185,20 +190,37 @@ public sealed class TemplateRouteService( x.ByProject == byProject, cancellationToken); - private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) + private async Task ResolveStepAsync( + TemplateRouteStep step, string initiatorEmail, string initiatorCompany, + IReadOnlyList projectExecutors, CancellationToken cancellationToken) { - if (step.ProjectRole is not (null or ProjectRole.None)) + string? resolvedEmail; + + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + resolvedEmail = step.ExecutorEmail; + else if (!string.IsNullOrWhiteSpace(step.Position)) + resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken); + else if (step.ProjectRole is not (null or ProjectRole.None)) + resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email; + else if (step.HeadType is not (null or HeadType.None)) + resolvedEmail = step.HeadType switch + { + HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken), + HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken), + HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, + _ => null + }; + else + resolvedEmail = null; + + string? resolvedPosition = null; + if (!string.IsNullOrWhiteSpace(resolvedEmail)) { - var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); - if (projectExecutor is not null) - return new ResolvedRouteStepDto( - step.Id, step.OrderNo, projectExecutor.Email, step.Position, - step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); + var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken); + resolvedPosition = resolvedEmployee?.Position; } - return new ResolvedRouteStepDto( - step.Id, step.OrderNo, step.ExecutorEmail, step.Position, - step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); + return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers); } private static TemplateRouteDto ToDto(TemplateRoute e) => new( diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 17b73e1..0a46479 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -17,32 +17,33 @@ public sealed class HttpTnsuPortalClient( public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) { - using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}"); - await AuthorizeAsync(request, cancellationToken); - - using var response = await httpClient.SendAsync(request, cancellationToken); - if (response.StatusCode == HttpStatusCode.NotFound) - return null; - response.EnsureSuccessStatusCode(); - - var employee = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var employee = await GetAsync( + $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken); if (employee is null) return null; - return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId); + return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position); + } + + public async Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken) + { + var employee = await GetAsync( + $"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken); + + return employee?.Email; + } + + public async Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) + { + var manager = await GetAsync( + $"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken); + + return manager?.ManagerEmail; } public async Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) { - using var request = new HttpRequestMessage(HttpMethod.Get, $"/Project/{projectId}"); - await AuthorizeAsync(request, cancellationToken); - - using var response = await httpClient.SendAsync(request, cancellationToken); - if (response.StatusCode == HttpStatusCode.NotFound) - return []; - response.EnsureSuccessStatusCode(); - - var project = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + var project = await GetAsync($"/Project/{projectId}", cancellationToken); if (project is null) return []; @@ -55,6 +56,19 @@ public sealed class HttpTnsuPortalClient( return executors; } + private async Task GetAsync(string requestUri, CancellationToken cancellationToken) where T : class + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + await AuthorizeAsync(request, cancellationToken); + + using var response = await httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + private static void AddIfPresent(List executors, ProjectRole role, string? email) { if (!string.IsNullOrWhiteSpace(email)) @@ -75,8 +89,14 @@ public sealed class HttpTnsuPortalClient( } private sealed record EmployeeResponse( + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("company")] string? Company, [property: JsonPropertyName("companyId")] Guid? CompanyId, - [property: JsonPropertyName("departmentId")] Guid? DepartmentId); + [property: JsonPropertyName("departmentId")] Guid? DepartmentId, + [property: JsonPropertyName("position")] string? Position); + + private sealed record ManagerResponse( + [property: JsonPropertyName("managerEmail")] string? ManagerEmail); private sealed record ProjectResponse( [property: JsonPropertyName("projectManager")] string? ProjectManager, diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 1c21425..4b5a5c6 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -4,5 +4,9 @@ public interface ITnsuPortalClient { Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); + Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken); + + Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); + Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 0abb76b..29fe375 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId); +public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs index 47469a7..a0adf35 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs @@ -7,4 +7,5 @@ public sealed class TnsuPortalOptions public string ClientId { get; set; } = string.Empty; public string ClientSecret { get; set; } = string.Empty; public string Scope { get; set; } = string.Empty; + public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz"; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 0469a9f..e6ba8fe 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -15,21 +15,24 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId)); + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); portalClient .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); - _services = TestServiceProviderFactory.Build(fixture.ConnectionString, portalClient: portalClient.Object); + _services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); _service = _services.GetRequiredService(); _requestTypeService = _services.GetRequiredService(); } @@ -160,7 +163,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), CancellationToken.None); // Action @@ -187,7 +190,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), CancellationToken.None); // Action @@ -201,6 +204,119 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer")); + portalClient + .Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) + .ReturnsAsync("lead@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false), + 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("lead@tnsu.kz"); + resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer"); + } + + [Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")] + public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail() + { + // Arrange + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position")); + portalClient + .Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny())) + .ReturnsAsync("chief@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, 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), + 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("chief@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")] + public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail() + { + // Arrange + var portalClient = new Mock(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position")); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false), + 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("director.tansu@tnsu.kz"); + portalClient.Verify(x => x.GetManagerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() { -- 2.34.1 From 8479190ec7fb21a30b790570eee0147375b585a5 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 27 Jul 2026 17:10:45 +0500 Subject: [PATCH 38/38] =?UTF-8?q?=D1=81=D1=85=D0=BB=D0=BE=D0=BF=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRoutes/TemplateRouteService.cs | 20 +++++++- .../TemplateRouteServiceTests.cs | 50 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 60a56e9..3acf480 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -177,7 +177,9 @@ public sealed class TemplateRouteService( foreach (var step in matchingSteps) steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken)); - return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps)); + var collapsedSteps = CollapseByExecutorEmail(steps); + + return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps)); } private Task HasDuplicateAsync( @@ -223,6 +225,22 @@ public sealed class TemplateRouteService( return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers); } + private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) + { + var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + + foreach (var step in steps) + { + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail)) + continue; + + result.Add(step); + } + + return result; + } + private static TemplateRouteDto ToDto(TemplateRoute e) => new( e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index e6ba8fe..c20f332 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -178,6 +178,56 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + portalClient + .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny())) + .ReturnsAsync("dup@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + var requestTypeId = (await requestTypeService.CreateAsync( + new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id; + var route = await routeService.CreateAsync( + new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + 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[0].ExecutorEmail.ShouldBe("dup@tnsu.kz"); + resolved.Value.Steps[0].OrderNo.ShouldBe(1); + resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz"); + resolved.Value.Steps[1].OrderNo.ShouldBe(2); + } + [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { -- 2.34.1