апи для работы с маршрутами согласования #2
@@ -0,0 +1,40 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Install PostgreSQL
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y postgresql
|
||||||
|
sudo pg_ctlcluster 16 main start
|
||||||
|
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;"
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||||
|
run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="UserContentModel">
|
<component name="UserContentModel">
|
||||||
<attachedFolders />
|
<attachedFolders>
|
||||||
|
<Path>../.gitea/workflows</Path>
|
||||||
|
</attachedFolders>
|
||||||
<explicitIncludes />
|
<explicitIncludes />
|
||||||
<explicitExcludes />
|
<explicitExcludes />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -5,12 +5,24 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<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="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="Moq" Version="4.20.72" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="MediatR" Version="14.2.0" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.IntegrationTests", "tests\Tnsu.ApprovalRoute.IntegrationTests\Tnsu.ApprovalRoute.IntegrationTests.csproj", "{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -71,6 +75,18 @@ Global
|
|||||||
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU
|
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU
|
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU
|
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -80,5 +96,6 @@ Global
|
|||||||
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSchemes
|
||||||
|
{
|
||||||
|
public const string Dev = "Dev";
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSetup
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var tenantId = configuration["Entra:TenantId"];
|
||||||
|
var audience = configuration["Entra:Audience"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
|
||||||
|
{
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidIssuers =
|
||||||
|
[
|
||||||
|
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||||
|
$"https://sts.windows.net/{tenantId}/"
|
||||||
|
],
|
||||||
|
ValidAudiences = [audience, $"api://{audience}"]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddAuthentication(AuthSchemes.Dev)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser
|
||||||
|
{
|
||||||
|
public string? Email =>
|
||||||
|
httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
||||||
|
?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username");
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public sealed class DevAuthHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
IConfiguration configuration)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
|
||||||
|
?? configuration["Dev:DefaultUserEmail"]
|
||||||
|
?? "dev@tnsu.kz";
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
|
||||||
|
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
|
||||||
|
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
|
||||||
|
public sealed record ApiResponse<T>(T Data);
|
||||||
|
|
||||||
|
public sealed record ApiError(IReadOnlyList<string> Messages);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
|
||||||
|
public static class ResultExtensions
|
||||||
|
{
|
||||||
|
public static IActionResult ToActionResult<T>(this Result<T> result)
|
||||||
|
{
|
||||||
|
if (result.IsFailed)
|
||||||
|
return result.Errors[0].ToActionResult();
|
||||||
|
|
||||||
|
return new OkObjectResult(new ApiResponse<T>(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IActionResult ToActionResult(this Result result)
|
||||||
|
{
|
||||||
|
if (result.IsFailed)
|
||||||
|
return result.Errors[0].ToActionResult();
|
||||||
|
|
||||||
|
return new NoContentResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IActionResult ToActionResult(this IError error) => error switch
|
||||||
|
{
|
||||||
|
NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])),
|
||||||
|
ValidationError => new BadRequestObjectResult(new ApiError([error.Message])),
|
||||||
|
ConflictError => new ConflictObjectResult(new ApiError([error.Message])),
|
||||||
|
_ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/request-types")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypesController(RequestTypeService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
|
||||||
|
(await service.GetAsync(id, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.UpdateAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/template-routes/{routeId:int}/steps")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(int routeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(routeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(int routeId, int id, CancellationToken ct) =>
|
||||||
|
(await service.GetAsync(routeId, id, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(routeId, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(routeId, id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int routeId, int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(routeId, id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/template-routes")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("resolve")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||||
|
(await service.ResolveAsync(request, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
|
||||||
|
(await service.GetAsync(id, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.UpdateAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Filters;
|
||||||
|
|
||||||
|
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 ApiError(result.Errors.Select(e => e.ErrorMessage).ToList()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,76 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Extensions.Hosting.AsyncInitialization;
|
using Extensions.Hosting.AsyncInitialization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.OpenApi;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
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.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||||
|
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||||
|
builder.Services.AddOpenApi(options =>
|
||||||
|
{
|
||||||
|
options.AddDocumentTransformer((document, _, _) =>
|
||||||
|
{
|
||||||
|
document.Components ??= new OpenApiComponents();
|
||||||
|
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||||
|
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
Description = "Введите JWT токен"
|
||||||
|
};
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
options.AddOperationTransformer((operation, context, _) =>
|
||||||
|
{
|
||||||
|
operation.Security =
|
||||||
|
[
|
||||||
|
new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[new OpenApiSecuritySchemeReference("Bearer", context.Document)] = []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
|
||||||
|
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
app.MapScalarApiReference(options =>
|
||||||
|
{
|
||||||
|
options
|
||||||
|
.WithTitle("Tnsu Approval Route")
|
||||||
|
.AddPreferredSecuritySchemes("Bearer")
|
||||||
|
.AddHttpAuthentication("Bearer", _ => { });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
await app.InitAndRunAsync();
|
await app.InitAndRunAsync();
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5080",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
<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>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,5 +7,20 @@
|
|||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||||
|
},
|
||||||
|
"Entra": {
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
|
||||||
|
},
|
||||||
|
"Dev": {
|
||||||
|
"DefaultUserEmail": "dev@tnsu.kz"
|
||||||
|
},
|
||||||
|
"TnsuPortal": {
|
||||||
|
"BaseUrl": "https://api.tnsu.kz",
|
||||||
|
"TenantId": "",
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"Scope": "",
|
||||||
|
"DirectorTansuEmail": "director.tansu@tnsu.kz"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
|
||||||
|
public sealed class NotFoundError(string entity, object id)
|
||||||
|
: Error($"{entity} с id {id} не найден.");
|
||||||
|
|
||||||
|
public sealed class ValidationError(string message) : Error(message);
|
||||||
|
|
||||||
|
public sealed class ConflictError(string message) : Error(message);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
public interface ICurrentUser
|
||||||
|
{
|
||||||
|
string? Email { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed record RequestTypeDto(int Id, string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record CreateRequestTypeRequest(string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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(int 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(int 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<RequestTypeDto>> PatchAsync(int id, PatchRequestTypeRequest 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.Name;
|
||||||
|
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int 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,32 @@
|
|||||||
|
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 страницы обязателен.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public PatchRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Name is not null, () =>
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
|
||||||
|
When(x => x.PageUrl is not null, () =>
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed record TemplateRouteStepDto(
|
||||||
|
int Id,
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
int RouteId,
|
||||||
|
bool AllowReassignToOtherApprovers,
|
||||||
|
string? Warning = null);
|
||||||
|
|
||||||
|
public sealed record CreateTemplateRouteStepRequest(
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record UpdateTemplateRouteStepRequest(
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record PatchTemplateRouteStepRequest(
|
||||||
|
int? OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus? Status,
|
||||||
|
bool? AllowReassignToOtherApprovers);
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
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(int 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(int routeId, int 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(int 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 is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
|
||||||
|
int routeId, int 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 is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> PatchAsync(
|
||||||
|
int routeId, int id, PatchTemplateRouteStepRequest 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));
|
||||||
|
|
||||||
|
var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail;
|
||||||
|
var position = r.Position ?? entity.Position;
|
||||||
|
var projectRole = r.ProjectRole ?? entity.ProjectRole;
|
||||||
|
var headType = r.HeadType ?? entity.HeadType;
|
||||||
|
var status = r.Status ?? entity.Status;
|
||||||
|
|
||||||
|
if (!route.ByProject && projectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
|
entity.ExecutorEmail = executorEmail;
|
||||||
|
entity.Position = position;
|
||||||
|
entity.ProjectRole = projectRole;
|
||||||
|
entity.HeadType = headType;
|
||||||
|
entity.Status = status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int routeId, int 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 Task<bool> HasDuplicateAsync(
|
||||||
|
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
|
||||||
|
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRouteSteps.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RouteId == routeId &&
|
||||||
|
x.ExecutorEmail == executorEmail &&
|
||||||
|
x.Position == position &&
|
||||||
|
x.ProjectRole == projectRole &&
|
||||||
|
x.HeadType == headType &&
|
||||||
|
x.Status == status,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||||
|
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? warning = null;
|
||||||
|
if (route.ByProject)
|
||||||
|
{
|
||||||
|
var hasProjectRoleStep = await db.TemplateRouteSteps
|
||||||
|
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
|
||||||
|
if (!hasProjectRoleStep)
|
||||||
|
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToDto(entity) with { Warning = 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);
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
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 => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<UpdateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public UpdateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TemplateRouteStepFields
|
||||||
|
{
|
||||||
|
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
|
||||||
|
(string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) +
|
||||||
|
(string.IsNullOrWhiteSpace(position) ? 0 : 1) +
|
||||||
|
(projectRole is null or ProjectRole.None ? 0 : 1) +
|
||||||
|
(headType is null or HeadType.None ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed record ResolvedRouteStepDto(
|
||||||
|
int Id,
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record ResolveTemplateRouteResultDto(
|
||||||
|
int RouteId,
|
||||||
|
RouteType RouteType,
|
||||||
|
bool RequiresControl,
|
||||||
|
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||||
|
|
||||||
|
public sealed record ResolveTemplateRouteRequest(
|
||||||
|
int RequestTypeId,
|
||||||
|
string? UserEmail,
|
||||||
|
Guid? ProjectId,
|
||||||
|
RouteStepStatus Status);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed record TemplateRouteDto(
|
||||||
|
int Id,
|
||||||
|
RouteType RouteType,
|
||||||
|
int RequestTypeId,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid CompanyId,
|
||||||
|
bool ByProject,
|
||||||
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record CreateTemplateRouteRequest(
|
||||||
|
RouteType RouteType,
|
||||||
|
int RequestTypeId,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid CompanyId,
|
||||||
|
bool ByProject,
|
||||||
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record UpdateTemplateRouteRequest(
|
||||||
|
RouteType RouteType,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid CompanyId,
|
||||||
|
bool ByProject,
|
||||||
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record PatchTemplateRouteRequest(
|
||||||
|
RouteType? RouteType,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? CompanyId,
|
||||||
|
bool? ByProject,
|
||||||
|
bool? RequiresControl);
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
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,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IOptions<TnsuPortalOptions> portalOptions)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? 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(int 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(int 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<TemplateRouteDto>> PatchAsync(int id, PatchTemplateRouteRequest 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 departmentId = r.DepartmentId ?? entity.DepartmentId;
|
||||||
|
var companyId = r.CompanyId ?? entity.CompanyId;
|
||||||
|
var byProject = r.ByProject ?? entity.ByProject;
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.RouteType = r.RouteType ?? entity.RouteType;
|
||||||
|
entity.DepartmentId = departmentId;
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.ByProject = byProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int 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);
|
||||||
|
if (employee is null)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
|
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||||||
|
|
||||||
|
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 matchingSteps = route.Steps
|
||||||
|
.Where(s => s.Status == request.Status)
|
||||||
|
.OrderBy(s => s.OrderNo)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var steps = new List<ResolvedRouteStepDto>();
|
||||||
|
foreach (var step in matchingSteps)
|
||||||
|
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken));
|
||||||
|
|
||||||
|
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||||
|
|
||||||
|
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> HasDuplicateAsync(
|
||||||
|
int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRoutes.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RequestTypeId == requestTypeId &&
|
||||||
|
x.DepartmentId == departmentId &&
|
||||||
|
x.CompanyId == companyId &&
|
||||||
|
x.ByProject == byProject,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||||
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||||
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? resolvedEmail;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||||
|
resolvedEmail = step.ExecutorEmail;
|
||||||
|
else if (!string.IsNullOrWhiteSpace(step.Position))
|
||||||
|
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||||||
|
else if (step.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
|
else if (step.HeadType is not (null or HeadType.None))
|
||||||
|
resolvedEmail = step.HeadType switch
|
||||||
|
{
|
||||||
|
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||||
|
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||||
|
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
else
|
||||||
|
resolvedEmail = null;
|
||||||
|
|
||||||
|
string? resolvedPosition = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
{
|
||||||
|
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||||
|
resolvedPosition = resolvedEmployee?.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||||
|
{
|
||||||
|
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<ResolvedRouteStepDto>();
|
||||||
|
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result.Add(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||||
|
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 PatchTemplateRouteRequestValidator : AbstractValidator<PatchTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public PatchTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.CompanyId is not null, () =>
|
||||||
|
RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public ResolveTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MediatR" />
|
<PackageReference Include="FluentResults" />
|
||||||
|
<PackageReference Include="FluentValidation" />
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
public class RequestType
|
public class RequestType
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string PageUrl { get; set; } = string.Empty;
|
public string PageUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
public ICollection<Route> Routes { get; set; } = new List<Route>();
|
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
|
|
||||||
public class Route
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
|
||||||
public Guid RequestTypeId { get; set; }
|
|
||||||
|
|
||||||
// Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД.
|
|
||||||
public Guid DepartmentId { get; set; }
|
|
||||||
public Guid CompanyId { get; set; }
|
|
||||||
public bool ByProject { get; set; }
|
|
||||||
|
|
||||||
public bool RequiresControl { get; set; }
|
|
||||||
|
|
||||||
public RequestType? RequestType { get; set; }
|
|
||||||
public ICollection<RouteStep> Steps { get; set; } = new List<RouteStep>();
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
|
|
||||||
public class RouteStep
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public int OrderNo { get; set; }
|
|
||||||
|
|
||||||
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
|
|
||||||
public string ExecutorEmail { get; set; } = string.Empty;
|
|
||||||
public string Position { get; set; } = string.Empty;
|
|
||||||
public ProjectRole ProjectRole { get; set; } = ProjectRole.None;
|
|
||||||
|
|
||||||
public RouteStepStatus Status { get; set; } = RouteStepStatus.Draft;
|
|
||||||
public Guid RouteId { get; set; }
|
|
||||||
public bool AllowReassignToOtherApprovers { get; set; }
|
|
||||||
|
|
||||||
public Route? Route { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
public class TemplateRoute
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
|
||||||
|
public Guid? DepartmentId { get; set; }
|
||||||
|
public Guid CompanyId { get; set; }
|
||||||
|
public bool ByProject { get; set; }
|
||||||
|
|
||||||
|
public bool RequiresControl { get; set; }
|
||||||
|
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
public ICollection<TemplateRouteStep> Steps { get; set; } = new List<TemplateRouteStep>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
public class TemplateRouteStep
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int OrderNo { get; set; }
|
||||||
|
|
||||||
|
public string? ExecutorEmail { get; set; }
|
||||||
|
public string? Position { get; set; }
|
||||||
|
public ProjectRole? ProjectRole { get; set; }
|
||||||
|
public HeadType? HeadType { get; set; }
|
||||||
|
|
||||||
|
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
|
||||||
|
public int RouteId { get; set; }
|
||||||
|
public bool AllowReassignToOtherApprovers { get; set; }
|
||||||
|
|
||||||
|
public TemplateRoute? Route { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
public enum HeadType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Chief,
|
||||||
|
DirectionHead,
|
||||||
|
DirectorTansu
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
public enum ProjectRole
|
public enum ProjectRole
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Manager,
|
ProjectManager,
|
||||||
Engineer,
|
Planner,
|
||||||
Observer
|
ManagerHSE,
|
||||||
|
Security
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
public enum RouteStepStatus
|
public enum RouteStepStatus
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Draft,
|
Approval,
|
||||||
OnApproval,
|
Execution
|
||||||
Approved,
|
|
||||||
InExecution,
|
|
||||||
Completed,
|
|
||||||
Returned,
|
|
||||||
Closed
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -2,7 +2,9 @@ using Extensions.Hosting.AsyncInitialization;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure;
|
namespace Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -11,11 +13,21 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddApprovalRouteInfrastructure(
|
public static IServiceCollection AddApprovalRouteInfrastructure(
|
||||||
this IServiceCollection services, IConfiguration configuration)
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
services.AddDbContext<ApprovalRouteDbContext>(opt =>
|
services.AddDbContext<ApprovalRouteDbContext>(opt =>
|
||||||
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention());
|
||||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||||
|
|
||||||
|
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||||
|
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||||
|
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
public DbSet<Route> Routes => Set<Route>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<RouteStep> RouteSteps => Set<RouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -18,19 +18,21 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Route>(e =>
|
modelBuilder.Entity<TemplateRoute>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
|
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
|
||||||
|
e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RouteStep>(e =>
|
modelBuilder.Entity<TemplateRouteStep>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired();
|
e.Property(x => x.ExecutorEmail).HasMaxLength(256);
|
||||||
e.Property(x => x.Position).HasMaxLength(128).IsRequired();
|
e.Property(x => x.Position).HasMaxLength(128);
|
||||||
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
|
||||||
|
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
|
||||||
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
||||||
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
||||||
|
|||||||
+39
-30
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApprovalRouteDbContext))]
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
[Migration("20260722130455_InitialCreate")]
|
[Migration("20260727103117_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -27,11 +27,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -50,13 +52,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("ByProject")
|
b.Property<bool>("ByProject")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("by_project");
|
.HasColumnName("by_project");
|
||||||
@@ -65,12 +69,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("company_id");
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
b.Property<Guid>("DepartmentId")
|
b.Property<Guid?>("DepartmentId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("department_id");
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
b.Property<Guid>("RequestTypeId")
|
b.Property<int>("RequestTypeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("request_type_id");
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
b.Property<bool>("RequiresControl")
|
b.Property<bool>("RequiresControl")
|
||||||
@@ -84,49 +88,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("route_type");
|
.HasColumnName("route_type");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_routes");
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
b.HasIndex("RequestTypeId")
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
.HasDatabaseName("ix_routes_request_type_id");
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
b.ToTable("routes", (string)null);
|
b.ToTable("template_routes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("AllowReassignToOtherApprovers")
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasColumnName("executor_email");
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
b.Property<int>("OrderNo")
|
b.Property<int>("OrderNo")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("order_no");
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
b.Property<string>("Position")
|
b.Property<string>("Position")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasColumnName("position");
|
.HasColumnName("position");
|
||||||
|
|
||||||
b.Property<string>("ProjectRole")
|
b.Property<string>("ProjectRole")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("project_role");
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
b.Property<Guid>("RouteId")
|
b.Property<int>("RouteId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("route_id");
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
@@ -136,34 +145,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("status");
|
.HasColumnName("status");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_route_steps");
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
b.HasIndex("RouteId", "OrderNo")
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
.HasDatabaseName("ix_route_steps_route_id_order_no");
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
b.ToTable("route_steps", (string)null);
|
b.ToTable("template_route_steps", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
.WithMany("Routes")
|
.WithMany("Routes")
|
||||||
.HasForeignKey("RequestTypeId")
|
.HasForeignKey("RequestTypeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_routes_request_types_request_type_id");
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
.WithMany("Steps")
|
.WithMany("Steps")
|
||||||
.HasForeignKey("RouteId")
|
.HasForeignKey("RouteId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_route_steps_routes_route_id");
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
b.Navigation("Route");
|
b.Navigation("Route");
|
||||||
});
|
});
|
||||||
@@ -173,7 +182,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Steps");
|
b.Navigation("Steps");
|
||||||
});
|
});
|
||||||
+29
-23
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
name: "request_types",
|
name: "request_types",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
page_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
|
page_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
|
||||||
},
|
},
|
||||||
@@ -25,22 +27,23 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "routes",
|
name: "template_routes",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
request_type_id = table.Column<Guid>(type: "uuid", nullable: false),
|
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
department_id = table.Column<Guid>(type: "uuid", nullable: false),
|
department_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
company_id = table.Column<Guid>(type: "uuid", nullable: false),
|
company_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
by_project = table.Column<bool>(type: "boolean", nullable: false),
|
by_project = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
requires_control = table.Column<bool>(type: "boolean", nullable: false)
|
requires_control = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_routes", x => x.id);
|
table.PrimaryKey("pk_template_routes", x => x.id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_routes_request_types_request_type_id",
|
name: "fk_template_routes_request_types_request_type_id",
|
||||||
column: x => x.request_type_id,
|
column: x => x.request_type_id,
|
||||||
principalTable: "request_types",
|
principalTable: "request_types",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
@@ -48,48 +51,51 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "route_steps",
|
name: "template_route_steps",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
order_no = table.Column<int>(type: "integer", nullable: false),
|
order_no = table.Column<int>(type: "integer", nullable: false),
|
||||||
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
route_id = table.Column<Guid>(type: "uuid", nullable: false),
|
route_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
|
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_route_steps", x => x.id);
|
table.PrimaryKey("pk_template_route_steps", x => x.id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_route_steps_routes_route_id",
|
name: "fk_template_route_steps_template_routes_route_id",
|
||||||
column: x => x.route_id,
|
column: x => x.route_id,
|
||||||
principalTable: "routes",
|
principalTable: "template_routes",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_route_steps_route_id_order_no",
|
name: "ix_template_route_steps_route_id_order_no",
|
||||||
table: "route_steps",
|
table: "template_route_steps",
|
||||||
columns: new[] { "route_id", "order_no" });
|
columns: new[] { "route_id", "order_no" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_routes_request_type_id",
|
name: "ix_template_routes_request_type_id_department_id_company_id_by",
|
||||||
table: "routes",
|
table: "template_routes",
|
||||||
column: "request_type_id");
|
columns: new[] { "request_type_id", "department_id", "company_id", "by_project" },
|
||||||
|
unique: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "route_steps");
|
name: "template_route_steps");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "routes");
|
name: "template_routes");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "request_types");
|
name: "request_types");
|
||||||
+38
-29
@@ -24,11 +24,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -47,13 +49,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("ByProject")
|
b.Property<bool>("ByProject")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("by_project");
|
.HasColumnName("by_project");
|
||||||
@@ -62,12 +66,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("company_id");
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
b.Property<Guid>("DepartmentId")
|
b.Property<Guid?>("DepartmentId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("department_id");
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
b.Property<Guid>("RequestTypeId")
|
b.Property<int>("RequestTypeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("request_type_id");
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
b.Property<bool>("RequiresControl")
|
b.Property<bool>("RequiresControl")
|
||||||
@@ -81,49 +85,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("route_type");
|
.HasColumnName("route_type");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_routes");
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
b.HasIndex("RequestTypeId")
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
.HasDatabaseName("ix_routes_request_type_id");
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
b.ToTable("routes", (string)null);
|
b.ToTable("template_routes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("AllowReassignToOtherApprovers")
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasColumnName("executor_email");
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
b.Property<int>("OrderNo")
|
b.Property<int>("OrderNo")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("order_no");
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
b.Property<string>("Position")
|
b.Property<string>("Position")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasColumnName("position");
|
.HasColumnName("position");
|
||||||
|
|
||||||
b.Property<string>("ProjectRole")
|
b.Property<string>("ProjectRole")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("project_role");
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
b.Property<Guid>("RouteId")
|
b.Property<int>("RouteId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("route_id");
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
@@ -133,34 +142,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("status");
|
.HasColumnName("status");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_route_steps");
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
b.HasIndex("RouteId", "OrderNo")
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
.HasDatabaseName("ix_route_steps_route_id_order_no");
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
b.ToTable("route_steps", (string)null);
|
b.ToTable("template_route_steps", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
.WithMany("Routes")
|
.WithMany("Routes")
|
||||||
.HasForeignKey("RequestTypeId")
|
.HasForeignKey("RequestTypeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_routes_request_types_request_type_id");
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
.WithMany("Steps")
|
.WithMany("Steps")
|
||||||
.HasForeignKey("RouteId")
|
.HasForeignKey("RouteId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_route_steps_routes_route_id");
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
b.Navigation("Route");
|
b.Navigation("Route");
|
||||||
});
|
});
|
||||||
@@ -170,7 +179,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Steps");
|
b.Navigation("Steps");
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -12,6 +11,9 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed class HttpTnsuPortalClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
TnsuPortalTokenProvider tokenProvider,
|
||||||
|
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employee = await GetAsync<EmployeeResponse>(
|
||||||
|
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
|
||||||
|
if (employee is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employee = await GetAsync<EmployeeResponse>(
|
||||||
|
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
|
||||||
|
|
||||||
|
return employee?.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var manager = await GetAsync<ManagerResponse>(
|
||||||
|
$"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken);
|
||||||
|
|
||||||
|
return manager?.ManagerEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
||||||
|
if (project is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var executors = new List<ProjectExecutorInfo>();
|
||||||
|
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
|
||||||
|
AddIfPresent(executors, ProjectRole.Planner, project.Planner);
|
||||||
|
AddIfPresent(executors, ProjectRole.ManagerHSE, project.ManagerHSE);
|
||||||
|
AddIfPresent(executors, ProjectRole.Security, project.Security);
|
||||||
|
|
||||||
|
return executors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
await AuthorizeAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
|
executors.Add(new ProjectExecutorInfo(role, email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(incoming))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", incoming);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EmployeeResponse(
|
||||||
|
[property: JsonPropertyName("email")] string? Email,
|
||||||
|
[property: JsonPropertyName("company")] string? Company,
|
||||||
|
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
||||||
|
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
||||||
|
[property: JsonPropertyName("position")] string? Position);
|
||||||
|
|
||||||
|
private sealed record ManagerResponse(
|
||||||
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|
||||||
|
private sealed record ProjectResponse(
|
||||||
|
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
||||||
|
[property: JsonPropertyName("planner")] string? Planner,
|
||||||
|
[property: JsonPropertyName("managerHSE")] string? ManagerHSE,
|
||||||
|
[property: JsonPropertyName("security")] string? Security);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public interface ITnsuPortalClient
|
||||||
|
{
|
||||||
|
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position);
|
||||||
|
|
||||||
|
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed class TnsuPortalOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = "https://api.tnsu.kz";
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
public string Scope { get; set; } = string.Empty;
|
||||||
|
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
|
||||||
|
{
|
||||||
|
private string? _cachedToken;
|
||||||
|
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt)
|
||||||
|
return _cachedToken;
|
||||||
|
|
||||||
|
var opts = options.Value;
|
||||||
|
var response = await httpClient.PostAsync(
|
||||||
|
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "client_credentials",
|
||||||
|
["client_id"] = opts.ClientId,
|
||||||
|
["client_secret"] = opts.ClientSecret,
|
||||||
|
["scope"] = opts.Scope
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Не удалось получить токен для api.tnsu.kz.");
|
||||||
|
|
||||||
|
_cachedToken = payload.AccessToken;
|
||||||
|
_expiresAt = DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn - 60);
|
||||||
|
|
||||||
|
return _cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TokenResponse(
|
||||||
|
[property: JsonPropertyName("access_token")] string AccessToken,
|
||||||
|
[property: JsonPropertyName("expires_in")] int ExpiresIn);
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private readonly RequestTypeService _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService<RequestTypeService>();
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание типа заявки и получение его по Id возвращает сохранённые данные")]
|
||||||
|
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Name.ShouldBe("Vacation");
|
||||||
|
created.Value.PageUrl.ShouldBe("/vacation");
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.Id.ShouldBe(created.Value.Id);
|
||||||
|
fetched.Value.Name.ShouldBe("Vacation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Получение несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task GetAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.GetAsync(999999, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление типа заявки изменяет имя и url страницы")]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.Name.ShouldBe("New name");
|
||||||
|
updated.Value.PageUrl.ShouldBe("/new");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task UpdateAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.UpdateAsync(
|
||||||
|
999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч типа заявки меняет только переданное поле, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.Name.ShouldBe("Patched name");
|
||||||
|
patched.Value.PageUrl.ShouldBe("/original");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task PatchAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.PatchAsync(
|
||||||
|
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Удаление типа заявки убирает его из базы")]
|
||||||
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Список типов заявок включает ранее созданный тип")]
|
||||||
|
public async Task ListAsync_IncludesCreatedEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var list = await _service.ListAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
list.IsSuccess.ShouldBeTrue();
|
||||||
|
list.Value.ShouldContain(x => x.Id == created.Value.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Moq;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
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 string _connectionString;
|
||||||
|
private readonly ServiceProvider _services;
|
||||||
|
private readonly TemplateRouteService _service;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
_connectionString = fixture.ConnectionString;
|
||||||
|
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
|
||||||
|
|
||||||
|
_services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
_service = _services.GetRequiredService<TemplateRouteService>();
|
||||||
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateRequestTypeAsync(string name) =>
|
||||||
|
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
|
||||||
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Vacation");
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
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(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
|
||||||
|
public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")]
|
||||||
|
public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var duplicate = await _service.CreateAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
|
||||||
|
updated.Value.RequiresControl.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч маршрута меняет только переданные поля, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Patch route request");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.RouteType.ShouldBe(RouteType.Parallel);
|
||||||
|
patched.Value.RequiresControl.ShouldBeTrue();
|
||||||
|
patched.Value.CompanyId.ShouldBe(CompanyId);
|
||||||
|
patched.Value.DepartmentId.ShouldBe(DepartmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Удаление маршрута убирает его из базы")]
|
||||||
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Remote work");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")]
|
||||||
|
public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
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<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.RouteId.ShouldBe(route.Value.Id);
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")]
|
||||||
|
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("dup@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.Count.ShouldBe(2);
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].OrderNo.ShouldBe(1);
|
||||||
|
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
|
||||||
|
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
||||||
|
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Project request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с должностью подставляет email сотрудника, найденного по должности и компании инициатора")]
|
||||||
|
public async Task ResolveAsync_ByPosition_UsesEmployeeFoundByPositionAndCompany()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("lead@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("lead@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("chief@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position"));
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("director.tansu@tnsu.kz");
|
||||||
|
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
|
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.RouteId.ShouldBe(route.Value.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
|
||||||
|
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private readonly TemplateRouteStepService _service;
|
||||||
|
private readonly TemplateRouteService _routeService;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
var services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
_service = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
_routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
_requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> 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(DisplayName = "Создание шага для несуществующего маршрута возвращает неуспешный результат")]
|
||||||
|
public async Task CreateAsync_UnknownRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
999999,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Роль в проекте недопустима для шага маршрута без признака «по проекту»")]
|
||||||
|
public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Non project route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Повторное создание шага с теми же исполнителем, должностью, ролью, типом руководителя и статусом возвращает конфликт")]
|
||||||
|
public async Task CreateAsync_DuplicateStep_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
|
||||||
|
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false);
|
||||||
|
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var duplicate = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
|
||||||
|
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Head type only route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.HeadType.ShouldBe(HeadType.Chief);
|
||||||
|
created.Value.ExecutorEmail.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
|
||||||
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Simple route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
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(DisplayName = "Создание шага без роли в проекте на маршруте «по проекту» возвращает предупреждение")]
|
||||||
|
public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("By-project route without role", byProject: true);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Warning.ShouldNotBeNull();
|
||||||
|
created.Value.Warning.ShouldContain("ролью в проекте");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")]
|
||||||
|
public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("By-project route with role", byProject: true);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Warning.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.ExecutorEmail.ShouldBe("b@b.kz");
|
||||||
|
updated.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||||||
|
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч шага маршрута меняет только переданные поля, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
patched.Value.OrderNo.ShouldBe(2);
|
||||||
|
patched.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч, после которого заполнено больше одного из полей исполнителя, возвращает неуспешный результат")]
|
||||||
|
public async Task PatchAsync_ResultsInMoreThanOneFilledField_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
|
||||||
|
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
|
||||||
|
public async Task DeleteAsync_RemovesStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Npgsql;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
internal static class TestServiceProviderFactory
|
||||||
|
{
|
||||||
|
public static ServiceProvider Build(
|
||||||
|
string connectionString,
|
||||||
|
string? currentUserEmail = "initiator@tnsu.kz",
|
||||||
|
ITnsuPortalClient? portalClient = null)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
if (portalClient is not null)
|
||||||
|
services.AddSingleton(portalClient);
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="Moq" />
|
||||||
|
<PackageReference Include="Shouldly" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user