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 @@
+