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() {