From fa13e931904b896df0324595c38f5f19516ea9d9 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 24 Jul 2026 12:27:42 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5?= =?UTF-8?q?=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 @@ +