апи для работы с маршрутами согласования #2

Merged
admin merged 38 commits from initial-api into main 2026-07-27 13:14:32 +00:00
3 changed files with 124 additions and 0 deletions
Showing only changes of commit f29fee8339 - Show all commits
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Common; using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Api.Controllers; namespace Tnsu.ApprovalRoute.Api.Controllers;
@@ -11,6 +12,15 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[Route("api/template-routes")] [Route("api/template-routes")]
public sealed class TemplateRoutesController(ISender mediator) : ControllerBase public sealed class TemplateRoutesController(ISender mediator) : ControllerBase
{ {
[HttpGet("resolve")]
public async Task<IActionResult> Resolve(
[FromQuery] Guid requestTypeId,
[FromQuery] string? userEmail,
[FromQuery] Guid? projectId,
[FromQuery] RouteStepStatus status,
CancellationToken ct) =>
(await mediator.Send(new ResolveTemplateRouteQuery(requestTypeId, userEmail, projectId, status), ct)).ToActionResult();
[HttpGet] [HttpGet]
public async Task<IActionResult> List([FromQuery] Guid? requestTypeId, CancellationToken ct) => public async Task<IActionResult> List([FromQuery] Guid? requestTypeId, CancellationToken ct) =>
(await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult(); (await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult();
@@ -0,0 +1,19 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolvedRouteStepDto(
Guid Id,
int OrderNo,
string ExecutorEmail,
string Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers);
public sealed record ResolveTemplateRouteResultDto(
Guid RouteId,
RouteType RouteType,
bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps);
@@ -0,0 +1,95 @@
using FluentResults;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolveTemplateRouteQuery(
Guid RequestTypeId,
string? UserEmail,
Guid? ProjectId,
RouteStepStatus Status) : IRequest<Result<ResolveTemplateRouteResultDto>>;
public sealed class ResolveTemplateRouteHandler(
ApprovalRouteDbContext db,
HttpTnsuPortalClient portalClient,
ICurrentUser currentUser)
: IRequestHandler<ResolveTemplateRouteQuery, Result<ResolveTemplateRouteResultDto>>
{
public async Task<Result<ResolveTemplateRouteResultDto>> Handle(ResolveTemplateRouteQuery request, CancellationToken cancellationToken)
{
if (request.Status == RouteStepStatus.None)
return Result.Fail<ResolveTemplateRouteResultDto>(
new ValidationError("Статус обязателен: Approval или Execution."));
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
if (string.IsNullOrWhiteSpace(email))
return Result.Fail<ResolveTemplateRouteResultDto>(
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken);
if (!requestTypeExists)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
var byProject = request.ProjectId is not null;
var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == employee.DepartmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
request.RequestTypeId));
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
if (request.ProjectId is { } projectId)
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
var steps = route.Steps
.Where(s => s.Status == request.Status)
.OrderBy(s => s.OrderNo)
.Select(s => ResolveStep(s, projectExecutors))
.ToList();
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps));
}
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors)
{
// Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz),
// а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту.
if (step.ProjectRole != ProjectRole.None)
{
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole);
if (projectExecutor is not null)
return new ResolvedRouteStepDto(
step.Id, step.OrderNo, projectExecutor.Email, projectExecutor.Position,
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
}
return new ResolvedRouteStepDto(
step.Id, step.OrderNo, step.ExecutorEmail, step.Position,
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
}
}