Compare commits
4 Commits
c36902a80f
...
e201823f69
| Author | SHA1 | Date | |
|---|---|---|---|
| e201823f69 | |||
| 253fafa575 | |||
| 7cce18e67b | |||
| 0ddb9f0ba6 |
@@ -29,19 +29,6 @@ jobs:
|
|||||||
sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;"
|
sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;"
|
||||||
sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;"
|
sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;"
|
||||||
|
|
||||||
- name: Wait for Postgres
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if pg_isready -h localhost -p 5432 -U tnsu_approval_route -d tnsu_approval_route; then
|
|
||||||
echo "Postgres is ready"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Waiting for postgres... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Postgres did not become ready in time" >&2
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,18 @@
|
|||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||||
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" 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.Http" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="MediatR" Version="14.2.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" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Tnsu.ApprovalRoute.Api.Common;
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
@@ -9,25 +8,25 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/request-types")]
|
[Route("api/request-types")]
|
||||||
public sealed class RequestTypesController(ISender mediator) : ControllerBase
|
public sealed class RequestTypesController(RequestTypeService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||||
(await mediator.Send(new ListRequestTypesQuery(), ct)).ToActionResult();
|
(await service.ListAsync(ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
|
public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
|
||||||
(await mediator.Send(new GetRequestTypeQuery(id), ct)).ToActionResult();
|
(await service.GetAsync(id, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
|
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
|
||||||
(await mediator.Send(new CreateRequestTypeCommand(body.Name, body.PageUrl), ct)).ToActionResult();
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) =>
|
public async Task<IActionResult> Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) =>
|
||||||
(await mediator.Send(new UpdateRequestTypeCommand(id, body.Name, body.PageUrl), ct)).ToActionResult();
|
(await service.UpdateAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||||
(await mediator.Send(new DeleteRequestTypeCommand(id), ct)).ToActionResult();
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Tnsu.ApprovalRoute.Api.Common;
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
@@ -9,25 +8,25 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/template-routes/{routeId:guid}/steps")]
|
[Route("api/template-routes/{routeId:guid}/steps")]
|
||||||
public sealed class TemplateRouteStepsController(ISender mediator) : ControllerBase
|
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> List(Guid routeId, CancellationToken ct) =>
|
public async Task<IActionResult> List(Guid routeId, CancellationToken ct) =>
|
||||||
(await mediator.Send(new ListTemplateRouteStepsQuery(routeId), ct)).ToActionResult();
|
(await service.ListAsync(routeId, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> Get(Guid routeId, Guid id, CancellationToken ct) =>
|
public async Task<IActionResult> Get(Guid routeId, Guid id, CancellationToken ct) =>
|
||||||
(await mediator.Send(new GetTemplateRouteStepQuery(routeId, id), ct)).ToActionResult();
|
(await service.GetAsync(routeId, id, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
|
public async Task<IActionResult> Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
(await mediator.Send(new CreateTemplateRouteStepCommand(routeId, body), ct)).ToActionResult();
|
(await service.CreateAsync(routeId, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
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 mediator.Send(new UpdateTemplateRouteStepCommand(routeId, id, body), ct)).ToActionResult();
|
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid routeId, Guid id, CancellationToken ct) =>
|
public async Task<IActionResult> Delete(Guid routeId, Guid id, CancellationToken ct) =>
|
||||||
(await mediator.Send(new DeleteTemplateRouteStepCommand(routeId, id), ct)).ToActionResult();
|
(await service.DeleteAsync(routeId, id, ct)).ToActionResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,36 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
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;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/template-routes")]
|
[Route("api/template-routes")]
|
||||||
public sealed class TemplateRoutesController(ISender mediator) : ControllerBase
|
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("resolve")]
|
[HttpGet("resolve")]
|
||||||
public async Task<IActionResult> Resolve(
|
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||||
[FromQuery] Guid requestTypeId,
|
(await service.ResolveAsync(request, ct)).ToActionResult();
|
||||||
[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 service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
|
public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
|
||||||
(await mediator.Send(new GetTemplateRouteQuery(id), ct)).ToActionResult();
|
(await service.GetAsync(id, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
|
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
(await mediator.Send(new CreateTemplateRouteCommand(body), ct)).ToActionResult();
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
|
public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
(await mediator.Send(new UpdateTemplateRouteCommand(id, body), ct)).ToActionResult();
|
(await service.UpdateAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||||
(await mediator.Send(new DeleteTemplateRouteCommand(id), ct)).ToActionResult();
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Filters;
|
||||||
|
|
||||||
|
// Валидирует аргументы action'ов, для которых зарегистрирован IValidator<T> — без валидации внутри сервисов.
|
||||||
|
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
foreach (var argument in context.ActionArguments.Values)
|
||||||
|
{
|
||||||
|
if (argument is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType());
|
||||||
|
if (serviceProvider.GetService(validatorType) is not IValidator validator)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var validationContext = new ValidationContext<object>(argument);
|
||||||
|
var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted);
|
||||||
|
if (result.IsValid)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
context.Result = new BadRequestObjectResult(new
|
||||||
|
{
|
||||||
|
message = string.Join(" ", result.Errors.Select(e => e.ErrorMessage))
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Tnsu.ApprovalRoute.Api.Auth;
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
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;
|
||||||
@@ -44,7 +45,7 @@ builder.Services.AddOpenApi(options =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
|
||||||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="MediatR" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,9 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using MediatR;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application;
|
namespace Tnsu.ApprovalRoute.Application;
|
||||||
|
|
||||||
@@ -8,7 +11,12 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
services.AddScoped<RequestTypeService>();
|
||||||
|
services.AddScoped<TemplateRouteService>();
|
||||||
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
|
||||||
|
|
||||||
public sealed record CreateRequestTypeCommand(string Name, string PageUrl) : IRequest<Result<RequestTypeDto>>;
|
|
||||||
|
|
||||||
public sealed record UpdateRequestTypeCommand(Guid Id, string Name, string PageUrl) : IRequest<Result<RequestTypeDto>>;
|
|
||||||
|
|
||||||
public sealed record DeleteRequestTypeCommand(Guid Id) : IRequest<Result>;
|
|
||||||
|
|
||||||
public sealed class CreateRequestTypeHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<CreateRequestTypeCommand, Result<RequestTypeDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<RequestTypeDto>> Handle(CreateRequestTypeCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError("Имя типа заявки обязательно."));
|
|
||||||
if (string.IsNullOrWhiteSpace(request.PageUrl))
|
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError("Url страницы обязателен."));
|
|
||||||
|
|
||||||
var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl };
|
|
||||||
db.RequestTypes.Add(entity);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok(RequestTypeMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UpdateRequestTypeHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<UpdateRequestTypeCommand, Result<RequestTypeDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<RequestTypeDto>> Handle(UpdateRequestTypeCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
|
||||||
if (entity is null)
|
|
||||||
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", request.Id));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError("Имя типа заявки обязательно."));
|
|
||||||
if (string.IsNullOrWhiteSpace(request.PageUrl))
|
|
||||||
return Result.Fail<RequestTypeDto>(new ValidationError("Url страницы обязателен."));
|
|
||||||
|
|
||||||
entity.Name = request.Name;
|
|
||||||
entity.PageUrl = request.PageUrl;
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok(RequestTypeMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DeleteRequestTypeHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<DeleteRequestTypeCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(DeleteRequestTypeCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
|
||||||
if (entity is null)
|
|
||||||
return Result.Fail(new NotFoundError("Тип заявки", request.Id));
|
|
||||||
|
|
||||||
db.RequestTypes.Remove(entity);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class RequestTypeMapper
|
|
||||||
{
|
|
||||||
public static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
|
||||||
|
|
||||||
public sealed record GetRequestTypeQuery(Guid Id) : IRequest<Result<RequestTypeDto>>;
|
|
||||||
|
|
||||||
public sealed record ListRequestTypesQuery : IRequest<Result<IReadOnlyList<RequestTypeDto>>>;
|
|
||||||
|
|
||||||
public sealed class GetRequestTypeHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<GetRequestTypeQuery, Result<RequestTypeDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<RequestTypeDto>> Handle(GetRequestTypeQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.RequestTypes.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
|
||||||
|
|
||||||
return entity is null
|
|
||||||
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", request.Id))
|
|
||||||
: Result.Ok(RequestTypeMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ListRequestTypesHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<ListRequestTypesQuery, Result<IReadOnlyList<RequestTypeDto>>>
|
|
||||||
{
|
|
||||||
public async Task<Result<IReadOnlyList<RequestTypeDto>>> Handle(ListRequestTypesQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var items = await db.RequestTypes.AsNoTracking()
|
|
||||||
.OrderBy(x => x.Name)
|
|
||||||
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.RequestTypes.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> GetAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl };
|
||||||
|
db.RequestTypes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> UpdateAsync(Guid id, UpdateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
entity.Name = request.Name;
|
||||||
|
entity.PageUrl = request.PageUrl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
db.RequestTypes.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed class CreateRequestTypeRequestValidator : AbstractValidator<CreateRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public CreateRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<UpdateRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public UpdateRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
|
}
|
||||||
|
}
|
||||||
-134
@@ -1,134 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
|
||||||
|
|
||||||
public sealed record CreateTemplateRouteStepCommand(Guid RouteId, CreateTemplateRouteStepRequest Request)
|
|
||||||
: IRequest<Result<TemplateRouteStepDto>>;
|
|
||||||
|
|
||||||
public sealed record UpdateTemplateRouteStepCommand(Guid RouteId, Guid Id, UpdateTemplateRouteStepRequest Request)
|
|
||||||
: IRequest<Result<TemplateRouteStepDto>>;
|
|
||||||
|
|
||||||
public sealed record DeleteTemplateRouteStepCommand(Guid RouteId, Guid Id) : IRequest<Result>;
|
|
||||||
|
|
||||||
public sealed class CreateTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<CreateTemplateRouteStepCommand, Result<TemplateRouteStepDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<TemplateRouteStepDto>> Handle(CreateTemplateRouteStepCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken);
|
|
||||||
if (route is null)
|
|
||||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", command.RouteId));
|
|
||||||
|
|
||||||
var r = command.Request;
|
|
||||||
var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject);
|
|
||||||
if (fieldsError is not null)
|
|
||||||
return Result.Fail<TemplateRouteStepDto>(fieldsError);
|
|
||||||
|
|
||||||
var entity = new TemplateRouteStep
|
|
||||||
{
|
|
||||||
RouteId = route.Id,
|
|
||||||
OrderNo = r.OrderNo,
|
|
||||||
ExecutorEmail = r.ExecutorEmail,
|
|
||||||
Position = r.Position,
|
|
||||||
ProjectRole = r.ProjectRole,
|
|
||||||
HeadType = r.HeadType,
|
|
||||||
Status = r.Status,
|
|
||||||
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers
|
|
||||||
};
|
|
||||||
db.TemplateRouteSteps.Add(entity);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity));
|
|
||||||
return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UpdateTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<UpdateTemplateRouteStepCommand, Result<TemplateRouteStepDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<TemplateRouteStepDto>> Handle(UpdateTemplateRouteStepCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken);
|
|
||||||
if (route is null)
|
|
||||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", command.RouteId));
|
|
||||||
|
|
||||||
var entity = await db.TemplateRouteSteps
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == command.Id && x.RouteId == command.RouteId, cancellationToken);
|
|
||||||
if (entity is null)
|
|
||||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", command.Id));
|
|
||||||
|
|
||||||
var r = command.Request;
|
|
||||||
var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject);
|
|
||||||
if (fieldsError is not null)
|
|
||||||
return Result.Fail<TemplateRouteStepDto>(fieldsError);
|
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo;
|
|
||||||
entity.ExecutorEmail = r.ExecutorEmail;
|
|
||||||
entity.Position = r.Position;
|
|
||||||
entity.ProjectRole = r.ProjectRole;
|
|
||||||
entity.HeadType = r.HeadType;
|
|
||||||
entity.Status = r.Status;
|
|
||||||
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity));
|
|
||||||
return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DeleteTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<DeleteTemplateRouteStepCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(DeleteTemplateRouteStepCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.TemplateRouteSteps
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken);
|
|
||||||
if (entity is null)
|
|
||||||
return Result.Fail(new NotFoundError("Шаг маршрута", request.Id));
|
|
||||||
|
|
||||||
db.TemplateRouteSteps.Remove(entity);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class TemplateRouteStepValidation
|
|
||||||
{
|
|
||||||
public static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && projectRole == ProjectRole.None)
|
|
||||||
return new ValidationError("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
|
|
||||||
|
|
||||||
if (!routeByProject && projectRole != ProjectRole.None)
|
|
||||||
return new ValidationError("Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).");
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<Result<TemplateRouteStepDto>> AppendMissingProjectRoleWarningAsync(
|
|
||||||
ApprovalRouteDbContext db, TemplateRoute route, Result<TemplateRouteStepDto> result, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!route.ByProject)
|
|
||||||
return result;
|
|
||||||
|
|
||||||
var hasProjectRoleStep = await db.TemplateRouteSteps
|
|
||||||
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
|
|
||||||
|
|
||||||
return hasProjectRoleStep
|
|
||||||
? result
|
|
||||||
: result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class TemplateRouteStepMapper
|
|
||||||
{
|
|
||||||
public static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
|
||||||
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers);
|
|
||||||
}
|
|
||||||
-41
@@ -1,41 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
|
||||||
|
|
||||||
public sealed record GetTemplateRouteStepQuery(Guid RouteId, Guid Id) : IRequest<Result<TemplateRouteStepDto>>;
|
|
||||||
|
|
||||||
public sealed record ListTemplateRouteStepsQuery(Guid RouteId) : IRequest<Result<IReadOnlyList<TemplateRouteStepDto>>>;
|
|
||||||
|
|
||||||
public sealed class GetTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<GetTemplateRouteStepQuery, Result<TemplateRouteStepDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<TemplateRouteStepDto>> Handle(GetTemplateRouteStepQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken);
|
|
||||||
|
|
||||||
return entity is null
|
|
||||||
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", request.Id))
|
|
||||||
: Result.Ok(TemplateRouteStepMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ListTemplateRouteStepsHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<ListTemplateRouteStepsQuery, Result<IReadOnlyList<TemplateRouteStepDto>>>
|
|
||||||
{
|
|
||||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> Handle(ListTemplateRouteStepsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
|
||||||
.Where(x => x.RouteId == request.RouteId)
|
|
||||||
.OrderBy(x => x.OrderNo)
|
|
||||||
.Select(x => new TemplateRouteStepDto(
|
|
||||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(Guid routeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.Where(x => x.RouteId == routeId)
|
||||||
|
.OrderBy(x => x.OrderNo)
|
||||||
|
.Select(x => new TemplateRouteStepDto(
|
||||||
|
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> GetAsync(Guid routeId, Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> CreateAsync(Guid routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
if (!route.ByProject && r.ProjectRole != ProjectRole.None)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var entity = new TemplateRouteStep
|
||||||
|
{
|
||||||
|
RouteId = route.Id,
|
||||||
|
OrderNo = r.OrderNo,
|
||||||
|
ExecutorEmail = r.ExecutorEmail,
|
||||||
|
Position = r.Position,
|
||||||
|
ProjectRole = r.ProjectRole,
|
||||||
|
HeadType = r.HeadType,
|
||||||
|
Status = r.Status,
|
||||||
|
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers
|
||||||
|
};
|
||||||
|
db.TemplateRouteSteps.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = Result.Ok(ToDto(entity));
|
||||||
|
return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
|
||||||
|
Guid routeId, Guid id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
if (!route.ByProject && r.ProjectRole != ProjectRole.None)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo;
|
||||||
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
|
entity.Position = r.Position;
|
||||||
|
entity.ProjectRole = r.ProjectRole;
|
||||||
|
entity.HeadType = r.HeadType;
|
||||||
|
entity.Status = r.Status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = Result.Ok(ToDto(entity));
|
||||||
|
return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(Guid routeId, Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
db.TemplateRouteSteps.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Result<TemplateRouteStepDto>> AppendMissingProjectRoleWarningAsync(
|
||||||
|
TemplateRoute route, Result<TemplateRouteStepDto> result, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!route.ByProject)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var hasProjectRoleStep = await db.TemplateRouteSteps
|
||||||
|
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
|
||||||
|
|
||||||
|
return hasProjectRoleStep
|
||||||
|
? result
|
||||||
|
: result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||||
|
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers);
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<CreateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public CreateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None))
|
||||||
|
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<UpdateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public UpdateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None))
|
||||||
|
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,9 @@ public sealed record ResolveTemplateRouteResultDto(
|
|||||||
RouteType RouteType,
|
RouteType RouteType,
|
||||||
bool RequiresControl,
|
bool RequiresControl,
|
||||||
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||||
|
|
||||||
|
public sealed record ResolveTemplateRouteRequest(
|
||||||
|
Guid RequestTypeId,
|
||||||
|
string? UserEmail,
|
||||||
|
Guid? ProjectId,
|
||||||
|
RouteStepStatus Status);
|
||||||
|
|||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
|
||||||
|
|
||||||
public sealed record CreateTemplateRouteCommand(CreateTemplateRouteRequest Request) : IRequest<Result<TemplateRouteDto>>;
|
|
||||||
|
|
||||||
public sealed record UpdateTemplateRouteCommand(Guid Id, UpdateTemplateRouteRequest Request) : IRequest<Result<TemplateRouteDto>>;
|
|
||||||
|
|
||||||
public sealed record DeleteTemplateRouteCommand(Guid Id) : IRequest<Result>;
|
|
||||||
|
|
||||||
public sealed class CreateTemplateRouteHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<CreateTemplateRouteCommand, Result<TemplateRouteDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<TemplateRouteDto>> Handle(CreateTemplateRouteCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var r = command.Request;
|
|
||||||
|
|
||||||
if (r.CompanyId == Guid.Empty)
|
|
||||||
return Result.Fail<TemplateRouteDto>(new ValidationError("Компания обязательна."));
|
|
||||||
|
|
||||||
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
|
|
||||||
if (!requestTypeExists)
|
|
||||||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
|
|
||||||
|
|
||||||
var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync(
|
|
||||||
db, r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
|
|
||||||
if (duplicate)
|
|
||||||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
|
||||||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
|
||||||
|
|
||||||
var entity = new TemplateRoute
|
|
||||||
{
|
|
||||||
RouteType = r.RouteType,
|
|
||||||
RequestTypeId = r.RequestTypeId,
|
|
||||||
DepartmentId = r.DepartmentId,
|
|
||||||
CompanyId = r.CompanyId,
|
|
||||||
ByProject = r.ByProject,
|
|
||||||
RequiresControl = r.RequiresControl
|
|
||||||
};
|
|
||||||
db.TemplateRoutes.Add(entity);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok(TemplateRouteMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class UpdateTemplateRouteHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<UpdateTemplateRouteCommand, Result<TemplateRouteDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<TemplateRouteDto>> Handle(UpdateTemplateRouteCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken);
|
|
||||||
if (entity is null)
|
|
||||||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", command.Id));
|
|
||||||
|
|
||||||
var r = command.Request;
|
|
||||||
|
|
||||||
if (r.CompanyId == Guid.Empty)
|
|
||||||
return Result.Fail<TemplateRouteDto>(new ValidationError("Компания обязательна."));
|
|
||||||
|
|
||||||
var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync(
|
|
||||||
db, entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
|
|
||||||
if (duplicate)
|
|
||||||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
|
||||||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
|
||||||
|
|
||||||
entity.RouteType = r.RouteType;
|
|
||||||
entity.DepartmentId = r.DepartmentId;
|
|
||||||
entity.CompanyId = r.CompanyId;
|
|
||||||
entity.ByProject = r.ByProject;
|
|
||||||
entity.RequiresControl = r.RequiresControl;
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok(TemplateRouteMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class DeleteTemplateRouteHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<DeleteTemplateRouteCommand, Result>
|
|
||||||
{
|
|
||||||
public async Task<Result> Handle(DeleteTemplateRouteCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
|
||||||
if (entity is null)
|
|
||||||
return Result.Fail(new NotFoundError("Маршрут", request.Id));
|
|
||||||
|
|
||||||
db.TemplateRoutes.Remove(entity);
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class TemplateRouteUniqueness
|
|
||||||
{
|
|
||||||
public static Task<bool> HasDuplicateAsync(
|
|
||||||
ApprovalRouteDbContext db,
|
|
||||||
Guid requestTypeId,
|
|
||||||
Guid? departmentId,
|
|
||||||
Guid companyId,
|
|
||||||
bool byProject,
|
|
||||||
Guid? excludeId,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
db.TemplateRoutes.AnyAsync(x =>
|
|
||||||
x.Id != excludeId &&
|
|
||||||
x.RequestTypeId == requestTypeId &&
|
|
||||||
x.DepartmentId == departmentId &&
|
|
||||||
x.CompanyId == companyId &&
|
|
||||||
x.ByProject == byProject,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class TemplateRouteMapper
|
|
||||||
{
|
|
||||||
public static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
|
||||||
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
|
||||||
|
|
||||||
public sealed record GetTemplateRouteQuery(Guid Id) : IRequest<Result<TemplateRouteDto>>;
|
|
||||||
|
|
||||||
public sealed record ListTemplateRoutesQuery(Guid? RequestTypeId) : IRequest<Result<IReadOnlyList<TemplateRouteDto>>>;
|
|
||||||
|
|
||||||
public sealed class GetTemplateRouteHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<GetTemplateRouteQuery, Result<TemplateRouteDto>>
|
|
||||||
{
|
|
||||||
public async Task<Result<TemplateRouteDto>> Handle(GetTemplateRouteQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var entity = await db.TemplateRoutes.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
|
||||||
|
|
||||||
return entity is null
|
|
||||||
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", request.Id))
|
|
||||||
: Result.Ok(TemplateRouteMapper.ToDto(entity));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ListTemplateRoutesHandler(ApprovalRouteDbContext db)
|
|
||||||
: IRequestHandler<ListTemplateRoutesQuery, Result<IReadOnlyList<TemplateRouteDto>>>
|
|
||||||
{
|
|
||||||
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> Handle(ListTemplateRoutesQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
|
|
||||||
if (request.RequestTypeId is { } requestTypeId)
|
|
||||||
query = query.Where(x => x.RequestTypeId == requestTypeId);
|
|
||||||
|
|
||||||
var items = await query
|
|
||||||
.Select(x => new TemplateRouteDto(
|
|
||||||
x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using FluentResults;
|
||||||
|
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 class TemplateRouteService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
HttpTnsuPortalClient portalClient,
|
||||||
|
ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
|
||||||
|
if (requestTypeId is { } id)
|
||||||
|
query = query.Where(x => x.RequestTypeId == id);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.Select(x => new TemplateRouteDto(
|
||||||
|
x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> GetAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
|
||||||
|
if (!requestTypeExists)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
var entity = new TemplateRoute
|
||||||
|
{
|
||||||
|
RouteType = r.RouteType,
|
||||||
|
RequestTypeId = r.RequestTypeId,
|
||||||
|
DepartmentId = r.DepartmentId,
|
||||||
|
CompanyId = r.CompanyId,
|
||||||
|
ByProject = r.ByProject,
|
||||||
|
RequiresControl = r.RequiresControl
|
||||||
|
};
|
||||||
|
db.TemplateRoutes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> UpdateAsync(Guid id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.RouteType = r.RouteType;
|
||||||
|
entity.DepartmentId = r.DepartmentId;
|
||||||
|
entity.CompanyId = r.CompanyId;
|
||||||
|
entity.ByProject = r.ByProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
db.TemplateRoutes.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ResolveTemplateRouteResultDto>> ResolveAsync(
|
||||||
|
ResolveTemplateRouteRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
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 Task<bool> HasDuplicateAsync(
|
||||||
|
Guid requestTypeId, Guid? departmentId, Guid companyId, bool byProject, Guid? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRoutes.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RequestTypeId == requestTypeId &&
|
||||||
|
x.DepartmentId == departmentId &&
|
||||||
|
x.CompanyId == companyId &&
|
||||||
|
x.ByProject == byProject,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||||
|
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public CreateTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<UpdateTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public UpdateTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public ResolveTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentResults" />
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="MediatR" />
|
<PackageReference Include="FluentValidation" />
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class RequestTypeServiceCollection : ICollectionFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
public const string Name = nameof(RequestTypeServiceCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(RequestTypeServiceCollection.Name)]
|
||||||
|
public sealed class RequestTypeServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _services;
|
||||||
|
private readonly RequestTypeService _service;
|
||||||
|
|
||||||
|
public RequestTypeServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
_service = _services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _services.Dispose();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
|
||||||
|
{
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None);
|
||||||
|
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Name.ShouldBe("Vacation");
|
||||||
|
created.Value.PageUrl.ShouldBe("/vacation");
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.Id.ShouldBe(created.Value.Id);
|
||||||
|
fetched.Value.Name.ShouldBe("Vacation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var result = await _service.GetAsync(Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None);
|
||||||
|
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.Name.ShouldBe("New name");
|
||||||
|
updated.Value.PageUrl.ShouldBe("/new");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var result = await _service.UpdateAsync(
|
||||||
|
Guid.NewGuid(), new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
|
{
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None);
|
||||||
|
|
||||||
|
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListAsync_IncludesCreatedEntity()
|
||||||
|
{
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None);
|
||||||
|
|
||||||
|
var list = await _service.ListAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
list.IsSuccess.ShouldBeTrue();
|
||||||
|
list.Value.ShouldContain(x => x.Id == created.Value.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class TemplateRouteServiceCollection : ICollectionFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
public const string Name = nameof(TemplateRouteServiceCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(TemplateRouteServiceCollection.Name)]
|
||||||
|
public sealed class TemplateRouteServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
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 readonly ServiceProvider _services;
|
||||||
|
private readonly TemplateRouteService _service;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
_service = _services.GetRequiredService<TemplateRouteService>();
|
||||||
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _services.Dispose();
|
||||||
|
|
||||||
|
private async Task<Guid> CreateRequestTypeAsync(string name) =>
|
||||||
|
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
||||||
|
{
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Vacation");
|
||||||
|
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.RequestTypeId.ShouldBe(requestTypeId);
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.CompanyId.ShouldBe(CompanyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, Guid.NewGuid(), DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Business trip");
|
||||||
|
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
|
||||||
|
|
||||||
|
var first = await _service.CreateAsync(request, CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var duplicate = await _service.CreateAsync(request, CancellationToken.None);
|
||||||
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
|
||||||
|
updated.Value.RequiresControl.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
|
{
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Remote work");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus()
|
||||||
|
{
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Exact match request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<Application.TemplateRouteSteps.TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new Application.TemplateRouteSteps.CreateTemplateRouteStepRequest(
|
||||||
|
1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.RouteId.ShouldBe(route.Value.Id);
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
|
||||||
|
{
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.RouteId.ShouldBe(route.Value.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(Guid.NewGuid(), "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
resolved.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
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.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class TemplateRouteStepServiceCollection : ICollectionFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
public const string Name = nameof(TemplateRouteStepServiceCollection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(TemplateRouteStepServiceCollection.Name)]
|
||||||
|
public sealed class TemplateRouteStepServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private readonly ServiceProvider _services;
|
||||||
|
private readonly TemplateRouteStepService _service;
|
||||||
|
private readonly TemplateRouteService _routeService;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
_service = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
_routeService = _services.GetRequiredService<TemplateRouteService>();
|
||||||
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _services.Dispose();
|
||||||
|
|
||||||
|
private async Task<Guid> CreateRouteAsync(string requestTypeName, bool byProject)
|
||||||
|
{
|
||||||
|
var requestTypeId = (await _requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
|
var route = await _routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
return route.Value.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_UnknownRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
var routeId = await CreateRouteAsync("Non project route", byProject: false);
|
||||||
|
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
|
||||||
|
{
|
||||||
|
var routeId = await CreateRouteAsync("Simple route", byProject: false);
|
||||||
|
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning()
|
||||||
|
{
|
||||||
|
var routeId = await CreateRouteAsync("By-project route without role", byProject: true);
|
||||||
|
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Reasons.ShouldContain(r => r.Message.Contains("ролью в проекте"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning()
|
||||||
|
{
|
||||||
|
var routeId = await CreateRouteAsync("By-project route with role", byProject: true);
|
||||||
|
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.Manager, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Reasons.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Execution, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.ExecutorEmail.ShouldBe("b@b.kz");
|
||||||
|
updated.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||||||
|
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_RemovesStep()
|
||||||
|
{
|
||||||
|
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Npgsql;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
// Отдельная БД на один xUnit-collection (= один тестовый класс) в общем инстансе Postgres.
|
||||||
|
// Тесты внутри класса делят эту БД; разные классы (разные коллекции) получают разные БД.
|
||||||
|
public sealed class PostgresDatabaseFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}";
|
||||||
|
private readonly NpgsqlConnectionStringBuilder _adminConnectionBuilder = new(TestSettings.BaseConnectionString);
|
||||||
|
|
||||||
|
public string ConnectionString { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await using (var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString))
|
||||||
|
{
|
||||||
|
await adminConnection.OpenAsync();
|
||||||
|
await using var createCommand = new NpgsqlCommand($"CREATE DATABASE \"{_databaseName}\"", adminConnection);
|
||||||
|
await createCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionBuilder.ConnectionString)
|
||||||
|
{
|
||||||
|
Database = _databaseName
|
||||||
|
}.ConnectionString;
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApprovalRouteDbContext>()
|
||||||
|
.UseNpgsql(ConnectionString)
|
||||||
|
.UseSnakeCaseNamingConvention()
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var db = new ApprovalRouteDbContext(options);
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await using var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString);
|
||||||
|
await adminConnection.OpenAsync();
|
||||||
|
|
||||||
|
await using (var terminateCommand = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||||
|
WHERE datname = @databaseName AND pid <> pg_backend_pid();
|
||||||
|
""",
|
||||||
|
adminConnection))
|
||||||
|
{
|
||||||
|
terminateCommand.Parameters.AddWithValue("databaseName", _databaseName);
|
||||||
|
await terminateCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var dropCommand = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{_databaseName}\"", adminConnection);
|
||||||
|
await dropCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
internal sealed class TestCurrentUser(string? email = null) : ICurrentUser
|
||||||
|
{
|
||||||
|
public string? Email { get; } = email;
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
// Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs),
|
||||||
|
// но с тестовой БД и тестовым ICurrentUser вместо HttpContext-based реализации.
|
||||||
|
internal static class TestServiceProviderFactory
|
||||||
|
{
|
||||||
|
public static ServiceProvider Build(string connectionString, string? currentUserEmail = "initiator@tnsu.kz")
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ConnectionStrings:Postgres"] = connectionString
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddApprovalRouteInfrastructure(configuration);
|
||||||
|
services.AddApprovalRouteApplication();
|
||||||
|
services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail));
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
internal static class TestSettings
|
||||||
|
{
|
||||||
|
public static string BaseConnectionString =>
|
||||||
|
Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION")
|
||||||
|
?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route";
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests;
|
|
||||||
|
|
||||||
public class RequestTypePostgresTests
|
|
||||||
{
|
|
||||||
private static string ConnectionString =>
|
|
||||||
Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION")
|
|
||||||
?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CanInsertAndReadBack_RealPostgres()
|
|
||||||
{
|
|
||||||
var options = new DbContextOptionsBuilder<ApprovalRouteDbContext>()
|
|
||||||
.UseNpgsql(ConnectionString)
|
|
||||||
.UseSnakeCaseNamingConvention()
|
|
||||||
.Options;
|
|
||||||
|
|
||||||
await using var db = new ApprovalRouteDbContext(options);
|
|
||||||
await db.Database.MigrateAsync();
|
|
||||||
|
|
||||||
// Транзакция без коммита — тест не оставляет данных в базе после себя.
|
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
|
||||||
|
|
||||||
var entity = new RequestType { Name = "Integration test type", PageUrl = "/integration-test" };
|
|
||||||
db.RequestTypes.Add(entity);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
db.ChangeTracker.Clear();
|
|
||||||
var loaded = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == entity.Id);
|
|
||||||
|
|
||||||
Assert.NotNull(loaded);
|
|
||||||
Assert.Equal("Integration test type", loaded.Name);
|
|
||||||
Assert.Equal("/integration-test", loaded.PageUrl);
|
|
||||||
|
|
||||||
await transaction.RollbackAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
@@ -6,11 +6,15 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="Shouldly" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user