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

Merged
admin merged 38 commits from initial-api into main 2026-07-27 13:14:32 +00:00
24 changed files with 271 additions and 99 deletions
Showing only changes of commit fa13e93190 - Show all commits
+1
View File
@@ -16,6 +16,7 @@
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Shouldly" Version="4.3.0" /> <PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />
@@ -0,0 +1,5 @@
namespace Tnsu.ApprovalRoute.Api.Common;
public sealed record ApiResponse<T>(T Data, IReadOnlyList<string> Warnings);
public sealed record ApiError(string Message);
@@ -12,7 +12,7 @@ public static class ResultExtensions
return result.Errors[0].ToActionResult(); return result.Errors[0].ToActionResult();
var warnings = result.Reasons.OfType<Warning>().Select(w => w.Message).ToList(); var warnings = result.Reasons.OfType<Warning>().Select(w => w.Message).ToList();
return new OkObjectResult(new { data = result.Value, warnings }); return new OkObjectResult(new ApiResponse<T>(result.Value, warnings));
} }
public static IActionResult ToActionResult(this Result result) public static IActionResult ToActionResult(this Result result)
@@ -25,9 +25,9 @@ public static class ResultExtensions
private static IActionResult ToActionResult(this IError error) => error switch private static IActionResult ToActionResult(this IError error) => error switch
{ {
NotFoundError => new NotFoundObjectResult(new { message = error.Message }), NotFoundError => new NotFoundObjectResult(new ApiError(error.Message)),
ValidationError => new BadRequestObjectResult(new { message = error.Message }), ValidationError => new BadRequestObjectResult(new ApiError(error.Message)),
ConflictError => new ConflictObjectResult(new { message = error.Message }), ConflictError => new ConflictObjectResult(new ApiError(error.Message)),
_ => new ObjectResult(new { message = error.Message }) { StatusCode = StatusCodes.Status500InternalServerError } _ => new ObjectResult(new ApiError(error.Message)) { StatusCode = StatusCodes.Status500InternalServerError }
}; };
} }
@@ -8,25 +8,36 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/request-types")] [Route("api/request-types")]
[Produces("application/json")]
public sealed class RequestTypesController(RequestTypeService service) : ControllerBase public sealed class RequestTypesController(RequestTypeService service) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(CancellationToken ct) => public async Task<IActionResult> List(CancellationToken ct) =>
(await service.ListAsync(ct)).ToActionResult(); (await service.ListAsync(ct)).ToActionResult();
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id, CancellationToken ct) => public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToActionResult(); (await service.GetAsync(id, ct)).ToActionResult();
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) => public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult(); (await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => public async Task<IActionResult> Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult(); (await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) => public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
(await service.DeleteAsync(id, ct)).ToActionResult(); (await service.DeleteAsync(id, ct)).ToActionResult();
} }
@@ -8,25 +8,37 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/template-routes/{routeId:guid}/steps")] [Route("api/template-routes/{routeId:guid}/steps")]
[Produces("application/json")]
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(Guid routeId, CancellationToken ct) => public async Task<IActionResult> List(Guid routeId, CancellationToken ct) =>
(await service.ListAsync(routeId, ct)).ToActionResult(); (await service.ListAsync(routeId, ct)).ToActionResult();
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid routeId, Guid id, CancellationToken ct) => public async Task<IActionResult> Get(Guid routeId, Guid id, CancellationToken ct) =>
(await service.GetAsync(routeId, id, ct)).ToActionResult(); (await service.GetAsync(routeId, id, ct)).ToActionResult();
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => public async Task<IActionResult> Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.CreateAsync(routeId, body, ct)).ToActionResult(); (await service.CreateAsync(routeId, body, ct)).ToActionResult();
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => public async Task<IActionResult> Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid routeId, Guid id, CancellationToken ct) => public async Task<IActionResult> Delete(Guid routeId, Guid id, CancellationToken ct) =>
(await service.DeleteAsync(routeId, id, ct)).ToActionResult(); (await service.DeleteAsync(routeId, id, ct)).ToActionResult();
} }
@@ -8,29 +8,46 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/template-routes")] [Route("api/template-routes")]
[Produces("application/json")]
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
{ {
[HttpGet("resolve")] [HttpGet("resolve")]
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) => public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
(await service.ResolveAsync(request, ct)).ToActionResult(); (await service.ResolveAsync(request, ct)).ToActionResult();
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] Guid? requestTypeId, CancellationToken ct) => public async Task<IActionResult> List([FromQuery] Guid? requestTypeId, CancellationToken ct) =>
(await service.ListAsync(requestTypeId, ct)).ToActionResult(); (await service.ListAsync(requestTypeId, ct)).ToActionResult();
[HttpGet("{id:guid}")] [HttpGet("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id, CancellationToken ct) => public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToActionResult(); (await service.GetAsync(id, ct)).ToActionResult();
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) => public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult(); (await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult(); (await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) => public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
(await service.DeleteAsync(id, ct)).ToActionResult(); (await service.DeleteAsync(id, ct)).ToActionResult();
} }
@@ -1,6 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Tnsu.ApprovalRoute.Api.Common;
namespace Tnsu.ApprovalRoute.Api.Filters; namespace Tnsu.ApprovalRoute.Api.Filters;
@@ -23,10 +24,8 @@ public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : I
if (result.IsValid) if (result.IsValid)
continue; continue;
context.Result = new BadRequestObjectResult(new context.Result = new BadRequestObjectResult(
{ new ApiError(string.Join(" ", result.Errors.Select(e => e.ErrorMessage))));
message = string.Join(" ", result.Errors.Select(e => e.ErrorMessage))
});
return; return;
} }
@@ -6,22 +6,22 @@ public sealed record TemplateRouteDto(
Guid Id, Guid Id,
RouteType RouteType, RouteType RouteType,
Guid RequestTypeId, Guid RequestTypeId,
Guid? DepartmentId, string? Department,
Guid CompanyId, string Company,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record CreateTemplateRouteRequest( public sealed record CreateTemplateRouteRequest(
RouteType RouteType, RouteType RouteType,
Guid RequestTypeId, Guid RequestTypeId,
Guid? DepartmentId, string? Department,
Guid CompanyId, string Company,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record UpdateTemplateRouteRequest( public sealed record UpdateTemplateRouteRequest(
RouteType RouteType, RouteType RouteType,
Guid? DepartmentId, string? Department,
Guid CompanyId, string Company,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
@@ -11,7 +11,7 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class TemplateRouteService( public sealed class TemplateRouteService(
ApprovalRouteDbContext db, ApprovalRouteDbContext db,
HttpTnsuPortalClient portalClient, ITnsuPortalClient portalClient,
ICurrentUser currentUser) ICurrentUser currentUser)
{ {
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken)
@@ -22,7 +22,7 @@ public sealed class TemplateRouteService(
var items = await query var items = await query
.Select(x => new TemplateRouteDto( .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
@@ -43,7 +43,8 @@ public sealed class TemplateRouteService(
if (!requestTypeExists) if (!requestTypeExists)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId)); return Result.Fail<TemplateRouteDto>(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) if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError( return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
@@ -52,8 +53,8 @@ public sealed class TemplateRouteService(
{ {
RouteType = r.RouteType, RouteType = r.RouteType,
RequestTypeId = r.RequestTypeId, RequestTypeId = r.RequestTypeId,
DepartmentId = r.DepartmentId, Department = department,
CompanyId = r.CompanyId, Company = r.Company,
ByProject = r.ByProject, ByProject = r.ByProject,
RequiresControl = r.RequiresControl RequiresControl = r.RequiresControl
}; };
@@ -69,14 +70,15 @@ public sealed class TemplateRouteService(
if (entity is null) if (entity is null)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id)); return Result.Fail<TemplateRouteDto>(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) if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError( return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType; entity.RouteType = r.RouteType;
entity.DepartmentId = r.DepartmentId; entity.Department = department;
entity.CompanyId = r.CompanyId; entity.Company = r.Company;
entity.ByProject = r.ByProject; entity.ByProject = r.ByProject;
entity.RequiresControl = r.RequiresControl; entity.RequiresControl = r.RequiresControl;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -109,23 +111,28 @@ public sealed class TemplateRouteService(
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId)); return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employee is null)
return Result.Fail<ResolveTemplateRouteResultDto>(
new NotFoundError("Сотрудник по email в справочнике api.tnsu.kz не найден", Guid.Empty));
var byProject = request.ProjectId is not null; var byProject = request.ProjectId is not null;
var department = Normalize(employee.Department);
var route = await db.TemplateRoutes.AsNoTracking() var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps) .Include(x => x.Steps)
.Where(x => .Where(x =>
x.RequestTypeId == request.RequestTypeId && x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId && x.Company == employee.Company &&
x.ByProject == byProject && x.ByProject == byProject &&
x.DepartmentId == employee.DepartmentId) x.Department == department)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking() ?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps) .Include(x => x.Steps)
.Where(x => .Where(x =>
x.RequestTypeId == request.RequestTypeId && x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId && x.Company == employee.Company &&
x.ByProject == byProject && x.ByProject == byProject &&
x.DepartmentId == null) x.Department == null)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (route is null) if (route is null)
@@ -147,25 +154,25 @@ public sealed class TemplateRouteService(
} }
private Task<bool> HasDuplicateAsync( private Task<bool> 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 => db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId && x.Id != excludeId &&
x.RequestTypeId == requestTypeId && x.RequestTypeId == requestTypeId &&
x.DepartmentId == departmentId && x.Department == department &&
x.CompanyId == companyId && x.Company == company &&
x.ByProject == byProject, x.ByProject == byProject,
cancellationToken); cancellationToken);
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors) private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors)
{ {
// Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz), // Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz);
// а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту. // должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона.
if (step.ProjectRole != ProjectRole.None) if (step.ProjectRole != ProjectRole.None)
{ {
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole);
if (projectExecutor is not null) if (projectExecutor is not null)
return new ResolvedRouteStepDto( 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); step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
} }
@@ -174,6 +181,8 @@ public sealed class TemplateRouteService(
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); 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( 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);
} }
@@ -7,7 +7,7 @@ public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<Crea
{ {
public CreateTemplateRouteRequestValidator() public CreateTemplateRouteRequestValidator()
{ {
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); RuleFor(x => x.Company).NotEmpty().WithMessage("Компания обязательна.");
} }
} }
@@ -15,7 +15,7 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<Upda
{ {
public UpdateTemplateRouteRequestValidator() public UpdateTemplateRouteRequestValidator()
{ {
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); RuleFor(x => x.Company).NotEmpty().WithMessage("Компания обязательна.");
} }
} }
@@ -8,9 +8,10 @@ public class TemplateRoute
public RouteType RouteType { get; set; } = RouteType.Sequential; public RouteType RouteType { get; set; } = RouteType.Sequential;
public Guid RequestTypeId { get; set; } public Guid RequestTypeId { get; set; }
// Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД. // Компания и отдел инициатора — названия из справочника AD (api-tnsu-portal /Employee/ByEmail),
public Guid? DepartmentId { get; set; } // хранятся строками без FK в этой БД. Отдел опционален, компания обязательна.
public Guid CompanyId { get; set; } public string? Department { get; set; }
public string Company { get; set; } = string.Empty;
public bool ByProject { get; set; } public bool ByProject { get; set; }
public bool RequiresControl { get; set; } public bool RequiresControl { get; set; }
@@ -1,9 +1,12 @@
namespace Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Domain.Enums;
// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal
// (project_manager / planner / manager_hse / security).
public enum ProjectRole public enum ProjectRole
{ {
None, None,
Manager, ProjectManager,
Engineer, Planner,
Observer ManagerHSE,
Security
} }
@@ -20,7 +20,7 @@ public static class InfrastructureServiceCollectionExtensions
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal")); services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>(); services.AddHttpClient<TnsuPortalTokenProvider>();
services.AddHttpClient<HttpTnsuPortalClient>((sp, client) => services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
{ {
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value; var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl); client.BaseAddress = new Uri(options.BaseUrl);
@@ -22,8 +22,10 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.RouteType).HasConversion<string>().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.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<TemplateRouteStep>(e => modelBuilder.Entity<TemplateRouteStep>(e =>
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(ApprovalRouteDbContext))] [DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260723074943_InitialCreate")] [Migration("20260724071302_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -61,13 +61,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
b.Property<Guid>("CompanyId") b.Property<string>("Company")
.HasColumnType("uuid") .IsRequired()
.HasColumnName("company_id"); .HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("company");
b.Property<Guid?>("DepartmentId") b.Property<string>("Department")
.HasColumnType("uuid") .HasMaxLength(256)
.HasColumnName("department_id"); .HasColumnType("character varying(256)")
.HasColumnName("department");
b.Property<Guid>("RequestTypeId") b.Property<Guid>("RequestTypeId")
.HasColumnType("uuid") .HasColumnType("uuid")
@@ -86,9 +89,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_routes"); .HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") b.HasIndex("RequestTypeId", "Department", "Company", "ByProject")
.IsUnique() .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); b.ToTable("template_routes", (string)null);
}); });
@@ -31,8 +31,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false), route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
request_type_id = table.Column<Guid>(type: "uuid", nullable: false), request_type_id = table.Column<Guid>(type: "uuid", nullable: false),
department_id = table.Column<Guid>(type: "uuid", nullable: true), department = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
company_id = table.Column<Guid>(type: "uuid", nullable: false), company = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
by_project = table.Column<bool>(type: "boolean", nullable: false), by_project = table.Column<bool>(type: "boolean", nullable: false),
requires_control = table.Column<bool>(type: "boolean", nullable: false) requires_control = table.Column<bool>(type: "boolean", nullable: false)
}, },
@@ -78,9 +78,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
columns: new[] { "route_id", "order_no" }); columns: new[] { "route_id", "order_no" });
migrationBuilder.CreateIndex( 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", 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); unique: true);
} }
@@ -58,13 +58,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
b.Property<Guid>("CompanyId") b.Property<string>("Company")
.HasColumnType("uuid") .IsRequired()
.HasColumnName("company_id"); .HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("company");
b.Property<Guid?>("DepartmentId") b.Property<string>("Department")
.HasColumnType("uuid") .HasMaxLength(256)
.HasColumnName("department_id"); .HasColumnType("character varying(256)")
.HasColumnName("department");
b.Property<Guid>("RequestTypeId") b.Property<Guid>("RequestTypeId")
.HasColumnType("uuid") .HasColumnType("uuid")
@@ -83,9 +86,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_routes"); .HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") b.HasIndex("RequestTypeId", "Department", "Company", "ByProject")
.IsUnique() .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); b.ToTable("template_routes", (string)null);
}); });
@@ -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; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
// Клиент api.tnsu.kz (api-tnsu-portal). GetEmployeeByEmailAsync и GetProjectExecutorsAsync — заглушки: // Клиент api.tnsu.kz (api-tnsu-portal). Сервисный токен прокидывается через TnsuPortalTokenProvider.
// на момент написания у api-tnsu-portal нет эндпоинта GET /Employee/ByEmail (есть только ByIIN/Manager), public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) : ITnsuPortalClient
// а форма ответа GET /Project/{oid} в его openapi-спеке не типизирована. Заменить на реальные вызовы,
// когда эндпоинты будут готовы — токен уже прокидывается через TnsuPortalTokenProvider.
public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider)
{ {
public Task<EmployeeDirectoryInfo> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{ {
return Task.FromResult(new EmployeeDirectoryInfo( using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}");
DepartmentId: Guid.Parse("00000000-0000-0000-0000-000000000001"), await AuthorizeAsync(request, cancellationToken);
CompanyId: Guid.Parse("00000000-0000-0000-0000-000000000002")));
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>(cancellationToken);
if (employee is null)
return null;
return new EmployeeDirectoryInfo(employee.Company ?? string.Empty, employee.Section ?? string.Empty);
} }
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
{ {
return Task.FromResult<IReadOnlyList<ProjectExecutorInfo>>([]); 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<ProjectResponse>(cancellationToken);
if (project is null)
return [];
// Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security).
var executors = new List<ProjectExecutorInfo>();
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<ProjectExecutorInfo> 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);
} }
@@ -0,0 +1,8 @@
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public interface ITnsuPortalClient
{
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
}
@@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; 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);
@@ -1,16 +1,19 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly; using Shouldly;
using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture> public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture>
{ {
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); private const string Company = "ТОО TANSU Construction";
private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); private const string Department = "IT";
private readonly ServiceProvider _services; private readonly ServiceProvider _services;
private readonly TemplateRouteService _service; private readonly TemplateRouteService _service;
@@ -18,7 +21,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture) public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
{ {
_services = TestServiceProviderFactory.Build(fixture.ConnectionString); var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(Company, Department));
portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, portalClient: portalClient.Object);
_service = _services.GetRequiredService<TemplateRouteService>(); _service = _services.GetRequiredService<TemplateRouteService>();
_requestTypeService = _services.GetRequiredService<RequestTypeService>(); _requestTypeService = _services.GetRequiredService<RequestTypeService>();
} }
@@ -34,7 +45,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -43,7 +54,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsSuccess.ShouldBeTrue(); fetched.IsSuccess.ShouldBeTrue();
fetched.Value.CompanyId.ShouldBe(CompanyId); fetched.Value.Company.ShouldBe(Company);
} }
[Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")] [Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
@@ -51,7 +62,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, Guid.NewGuid(), DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, Guid.NewGuid(), Department, Company, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -63,7 +74,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Business trip"); var requestTypeId = await CreateRequestTypeAsync("Business trip");
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false); var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: false);
var first = await _service.CreateAsync(request, CancellationToken.None); var first = await _service.CreateAsync(request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
@@ -80,13 +91,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave"); var requestTypeId = await CreateRequestTypeAsync("Sick leave");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), new UpdateTemplateRouteRequest(RouteType.Parallel, Department, Company, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -101,7 +112,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work"); var requestTypeId = await CreateRequestTypeAsync("Remote work");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -120,14 +131,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request"); var requestTypeId = await CreateRequestTypeAsync("Exact match request");
var route = await _service.CreateAsync( var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
var stepService = _services.GetRequiredService<Application.TemplateRouteSteps.TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new Application.TemplateRouteSteps.CreateTemplateRouteStepRequest( new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false),
1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -142,13 +152,39 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
} }
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: true, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
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 = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
{ {
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
var route = await _service.CreateAsync( 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); CancellationToken.None);
// Action // Action
@@ -10,7 +10,7 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture> public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture>
{ {
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); private const string Company = "ТОО TANSU Construction";
private readonly TemplateRouteStepService _service; private readonly TemplateRouteStepService _service;
private readonly TemplateRouteService _routeService; private readonly TemplateRouteService _routeService;
@@ -30,7 +30,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id;
var route = await _routeService.CreateAsync( var route = await _routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, Company, byProject, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
return route.Value.Id; return route.Value.Id;
@@ -58,7 +58,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -111,7 +111,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -3,14 +3,18 @@ using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure; using Tnsu.ApprovalRoute.Infrastructure;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
// Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs), // Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs),
// но с тестовой БД и тестовым ICurrentUser вместо HttpContext-based реализации. // но с тестовой БД, тестовым ICurrentUser и подменяемым (Moq) клиентом api.tnsu.kz.
internal static class TestServiceProviderFactory internal static class TestServiceProviderFactory
{ {
public static ServiceProvider Build(string connectionString, string? currentUserEmail = "initiator@tnsu.kz") public static ServiceProvider Build(
string connectionString,
string? currentUserEmail = "initiator@tnsu.kz",
ITnsuPortalClient? portalClient = null)
{ {
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(new Dictionary<string, string?>
@@ -24,6 +28,9 @@ internal static class TestServiceProviderFactory
services.AddApprovalRouteApplication(); services.AddApprovalRouteApplication();
services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail)); services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail));
if (portalClient is not null)
services.AddSingleton(portalClient);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
} }
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration" /> <PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="Shouldly" /> <PackageReference Include="Shouldly" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />