Compare commits
9 Commits
bc05df3f73
...
9b27f328a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b27f328a7 | |||
| 436c8ce7d4 | |||
| 52d3325f26 | |||
| f29fee8339 | |||
| 409093c444 | |||
| f2a448d985 | |||
| 1a5ed34182 | |||
| 719cd81391 | |||
| ccba53cc94 |
@@ -0,0 +1,47 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: tnsu_approval_route
|
||||
POSTGRES_USER: tnsu_approval_route
|
||||
POSTGRES_PASSWORD: tnsu_approval_route
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U tnsu_approval_route -d tnsu_approval_route"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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
|
||||
@@ -5,12 +5,19 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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.Design" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="MediatR" Version="14.2.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />
|
||||
<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>
|
||||
</Project>
|
||||
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -80,5 +96,6 @@ Global
|
||||
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {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}
|
||||
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
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,27 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID).
|
||||
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,33 @@
|
||||
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();
|
||||
|
||||
var warnings = result.Reasons.OfType<Warning>().Select(w => w.Message).ToList();
|
||||
return new OkObjectResult(new { data = result.Value, warnings });
|
||||
}
|
||||
|
||||
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 { message = error.Message }),
|
||||
ValidationError => new BadRequestObjectResult(new { message = error.Message }),
|
||||
ConflictError => new ConflictObjectResult(new { message = error.Message }),
|
||||
_ => new ObjectResult(new { message = error.Message }) { StatusCode = StatusCodes.Status500InternalServerError }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using MediatR;
|
||||
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")]
|
||||
public sealed class RequestTypesController(ISender mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||
(await mediator.Send(new ListRequestTypesQuery(), ct)).ToActionResult();
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
|
||||
(await mediator.Send(new GetRequestTypeQuery(id), ct)).ToActionResult();
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
|
||||
(await mediator.Send(new CreateRequestTypeCommand(body.Name, body.PageUrl), ct)).ToActionResult();
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) =>
|
||||
(await mediator.Send(new UpdateRequestTypeCommand(id, body.Name, body.PageUrl), ct)).ToActionResult();
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||
(await mediator.Send(new DeleteRequestTypeCommand(id), ct)).ToActionResult();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using MediatR;
|
||||
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:guid}/steps")]
|
||||
public sealed class TemplateRouteStepsController(ISender mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List(Guid routeId, CancellationToken ct) =>
|
||||
(await mediator.Send(new ListTemplateRouteStepsQuery(routeId), ct)).ToActionResult();
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid routeId, Guid id, CancellationToken ct) =>
|
||||
(await mediator.Send(new GetTemplateRouteStepQuery(routeId, id), ct)).ToActionResult();
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||
(await mediator.Send(new CreateTemplateRouteStepCommand(routeId, body), ct)).ToActionResult();
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||
(await mediator.Send(new UpdateTemplateRouteStepCommand(routeId, id, body), ct)).ToActionResult();
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid routeId, Guid id, CancellationToken ct) =>
|
||||
(await mediator.Send(new DeleteTemplateRouteStepCommand(routeId, id), ct)).ToActionResult();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/template-routes")]
|
||||
public sealed class TemplateRoutesController(ISender mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("resolve")]
|
||||
public async Task<IActionResult> Resolve(
|
||||
[FromQuery] Guid requestTypeId,
|
||||
[FromQuery] string? userEmail,
|
||||
[FromQuery] Guid? projectId,
|
||||
[FromQuery] RouteStepStatus status,
|
||||
CancellationToken ct) =>
|
||||
(await mediator.Send(new ResolveTemplateRouteQuery(requestTypeId, userEmail, projectId, status), ct)).ToActionResult();
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] Guid? requestTypeId, CancellationToken ct) =>
|
||||
(await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult();
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Get(Guid id, CancellationToken ct) =>
|
||||
(await mediator.Send(new GetTemplateRouteQuery(id), ct)).ToActionResult();
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
|
||||
(await mediator.Send(new CreateTemplateRouteCommand(body), ct)).ToActionResult();
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
|
||||
(await mediator.Send(new UpdateTemplateRouteCommand(id, body), ct)).ToActionResult();
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||
(await mediator.Send(new DeleteTemplateRouteCommand(id), ct)).ToActionResult();
|
||||
}
|
||||
@@ -1,24 +1,73 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Extensions.Hosting.AsyncInitialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.OpenApi;
|
||||
using Tnsu.ApprovalRoute.Api.Auth;
|
||||
using Tnsu.ApprovalRoute.Application;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Infrastructure;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||
builder.Services.AddApprovalRouteApplication();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
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.AddControllers()
|
||||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/openapi/v1.json", "Tnsu Approval Route v1");
|
||||
c.RoutePrefix = string.Empty;
|
||||
c.ConfigObject.AdditionalItems["persistAuthorization"] = true;
|
||||
c.EnableTryItOutByDefault();
|
||||
});
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
await app.InitAndRunAsync();
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,5 +7,19 @@
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||
},
|
||||
"Entra": {
|
||||
"TenantId": "",
|
||||
"Audience": ""
|
||||
},
|
||||
"Dev": {
|
||||
"DefaultUserEmail": "dev@tnsu.kz"
|
||||
},
|
||||
"TnsuPortal": {
|
||||
"BaseUrl": "https://api.tnsu.kz",
|
||||
"TenantId": "",
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"Scope": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentResults;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
|
||||
public sealed class NotFoundError(string entity, Guid id)
|
||||
: Error($"{entity} с id {id} не найден.");
|
||||
|
||||
public sealed class ValidationError(string message) : Error(message);
|
||||
|
||||
public sealed class ConflictError(string message) : Error(message);
|
||||
|
||||
public sealed class Warning(string message) : Success(message);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
|
||||
public interface ICurrentUser
|
||||
{
|
||||
string? Email { get; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
|
||||
public sealed record CreateRequestTypeCommand(string Name, string PageUrl) : IRequest<Result<RequestTypeDto>>;
|
||||
|
||||
public sealed record UpdateRequestTypeCommand(Guid Id, string Name, string PageUrl) : IRequest<Result<RequestTypeDto>>;
|
||||
|
||||
public sealed record DeleteRequestTypeCommand(Guid Id) : IRequest<Result>;
|
||||
|
||||
public sealed class CreateRequestTypeHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<CreateRequestTypeCommand, Result<RequestTypeDto>>
|
||||
{
|
||||
public async Task<Result<RequestTypeDto>> Handle(CreateRequestTypeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Result.Fail<RequestTypeDto>(new ValidationError("Имя типа заявки обязательно."));
|
||||
if (string.IsNullOrWhiteSpace(request.PageUrl))
|
||||
return Result.Fail<RequestTypeDto>(new ValidationError("Url страницы обязателен."));
|
||||
|
||||
var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl };
|
||||
db.RequestTypes.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(RequestTypeMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateRequestTypeHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<UpdateRequestTypeCommand, Result<RequestTypeDto>>
|
||||
{
|
||||
public async Task<Result<RequestTypeDto>> Handle(UpdateRequestTypeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", request.Id));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Result.Fail<RequestTypeDto>(new ValidationError("Имя типа заявки обязательно."));
|
||||
if (string.IsNullOrWhiteSpace(request.PageUrl))
|
||||
return Result.Fail<RequestTypeDto>(new ValidationError("Url страницы обязателен."));
|
||||
|
||||
entity.Name = request.Name;
|
||||
entity.PageUrl = request.PageUrl;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(RequestTypeMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteRequestTypeHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<DeleteRequestTypeCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteRequestTypeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(new NotFoundError("Тип заявки", request.Id));
|
||||
|
||||
db.RequestTypes.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RequestTypeMapper
|
||||
{
|
||||
public static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
|
||||
public sealed record RequestTypeDto(Guid Id, string Name, string PageUrl);
|
||||
|
||||
public sealed record CreateRequestTypeRequest(string Name, string PageUrl);
|
||||
|
||||
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl);
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
|
||||
public sealed record GetRequestTypeQuery(Guid Id) : IRequest<Result<RequestTypeDto>>;
|
||||
|
||||
public sealed record ListRequestTypesQuery : IRequest<Result<IReadOnlyList<RequestTypeDto>>>;
|
||||
|
||||
public sealed class GetRequestTypeHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<GetRequestTypeQuery, Result<RequestTypeDto>>
|
||||
{
|
||||
public async Task<Result<RequestTypeDto>> Handle(GetRequestTypeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
||||
|
||||
return entity is null
|
||||
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", request.Id))
|
||||
: Result.Ok(RequestTypeMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ListRequestTypesHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<ListRequestTypesQuery, Result<IReadOnlyList<RequestTypeDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<RequestTypeDto>>> Handle(ListRequestTypesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await db.RequestTypes.AsNoTracking()
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
public sealed record CreateTemplateRouteStepCommand(Guid RouteId, CreateTemplateRouteStepRequest Request)
|
||||
: IRequest<Result<TemplateRouteStepDto>>;
|
||||
|
||||
public sealed record UpdateTemplateRouteStepCommand(Guid RouteId, Guid Id, UpdateTemplateRouteStepRequest Request)
|
||||
: IRequest<Result<TemplateRouteStepDto>>;
|
||||
|
||||
public sealed record DeleteTemplateRouteStepCommand(Guid RouteId, Guid Id) : IRequest<Result>;
|
||||
|
||||
public sealed class CreateTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<CreateTemplateRouteStepCommand, Result<TemplateRouteStepDto>>
|
||||
{
|
||||
public async Task<Result<TemplateRouteStepDto>> Handle(CreateTemplateRouteStepCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken);
|
||||
if (route is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", command.RouteId));
|
||||
|
||||
var r = command.Request;
|
||||
var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject);
|
||||
if (fieldsError is not null)
|
||||
return Result.Fail<TemplateRouteStepDto>(fieldsError);
|
||||
|
||||
var entity = new TemplateRouteStep
|
||||
{
|
||||
RouteId = route.Id,
|
||||
OrderNo = r.OrderNo,
|
||||
ExecutorEmail = r.ExecutorEmail,
|
||||
Position = r.Position,
|
||||
ProjectRole = r.ProjectRole,
|
||||
HeadType = r.HeadType,
|
||||
Status = r.Status,
|
||||
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers
|
||||
};
|
||||
db.TemplateRouteSteps.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity));
|
||||
return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<UpdateTemplateRouteStepCommand, Result<TemplateRouteStepDto>>
|
||||
{
|
||||
public async Task<Result<TemplateRouteStepDto>> Handle(UpdateTemplateRouteStepCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken);
|
||||
if (route is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", command.RouteId));
|
||||
|
||||
var entity = await db.TemplateRouteSteps
|
||||
.FirstOrDefaultAsync(x => x.Id == command.Id && x.RouteId == command.RouteId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", command.Id));
|
||||
|
||||
var r = command.Request;
|
||||
var fieldsError = TemplateRouteStepValidation.ValidateFields(r.ExecutorEmail, r.Position, r.ProjectRole, route.ByProject);
|
||||
if (fieldsError is not null)
|
||||
return Result.Fail<TemplateRouteStepDto>(fieldsError);
|
||||
|
||||
entity.OrderNo = r.OrderNo;
|
||||
entity.ExecutorEmail = r.ExecutorEmail;
|
||||
entity.Position = r.Position;
|
||||
entity.ProjectRole = r.ProjectRole;
|
||||
entity.HeadType = r.HeadType;
|
||||
entity.Status = r.Status;
|
||||
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var result = Result.Ok(TemplateRouteStepMapper.ToDto(entity));
|
||||
return await TemplateRouteStepValidation.AppendMissingProjectRoleWarningAsync(db, route, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<DeleteTemplateRouteStepCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteTemplateRouteStepCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRouteSteps
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(new NotFoundError("Шаг маршрута", request.Id));
|
||||
|
||||
db.TemplateRouteSteps.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TemplateRouteStepValidation
|
||||
{
|
||||
public static IError? ValidateFields(string executorEmail, string position, ProjectRole projectRole, bool routeByProject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && projectRole == ProjectRole.None)
|
||||
return new ValidationError("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
|
||||
|
||||
if (!routeByProject && projectRole != ProjectRole.None)
|
||||
return new ValidationError("Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<Result<TemplateRouteStepDto>> AppendMissingProjectRoleWarningAsync(
|
||||
ApprovalRouteDbContext db, TemplateRoute route, Result<TemplateRouteStepDto> result, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!route.ByProject)
|
||||
return result;
|
||||
|
||||
var hasProjectRoleStep = await db.TemplateRouteSteps
|
||||
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
|
||||
|
||||
return hasProjectRoleStep
|
||||
? result
|
||||
: result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте."));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TemplateRouteStepMapper
|
||||
{
|
||||
public static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
public sealed record TemplateRouteStepDto(
|
||||
Guid Id,
|
||||
int OrderNo,
|
||||
string ExecutorEmail,
|
||||
string Position,
|
||||
ProjectRole ProjectRole,
|
||||
HeadType HeadType,
|
||||
RouteStepStatus Status,
|
||||
Guid RouteId,
|
||||
bool AllowReassignToOtherApprovers);
|
||||
|
||||
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);
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
public sealed record GetTemplateRouteStepQuery(Guid RouteId, Guid Id) : IRequest<Result<TemplateRouteStepDto>>;
|
||||
|
||||
public sealed record ListTemplateRouteStepsQuery(Guid RouteId) : IRequest<Result<IReadOnlyList<TemplateRouteStepDto>>>;
|
||||
|
||||
public sealed class GetTemplateRouteStepHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<GetTemplateRouteStepQuery, Result<TemplateRouteStepDto>>
|
||||
{
|
||||
public async Task<Result<TemplateRouteStepDto>> Handle(GetTemplateRouteStepQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id && x.RouteId == request.RouteId, cancellationToken);
|
||||
|
||||
return entity is null
|
||||
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", request.Id))
|
||||
: Result.Ok(TemplateRouteStepMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ListTemplateRouteStepsHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<ListTemplateRouteStepsQuery, Result<IReadOnlyList<TemplateRouteStepDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> Handle(ListTemplateRouteStepsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Where(x => x.RouteId == request.RouteId)
|
||||
.OrderBy(x => x.OrderNo)
|
||||
.Select(x => new TemplateRouteStepDto(
|
||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
|
||||
public sealed record ResolvedRouteStepDto(
|
||||
Guid Id,
|
||||
int OrderNo,
|
||||
string ExecutorEmail,
|
||||
string Position,
|
||||
ProjectRole ProjectRole,
|
||||
HeadType HeadType,
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers);
|
||||
|
||||
public sealed record ResolveTemplateRouteResultDto(
|
||||
Guid RouteId,
|
||||
RouteType RouteType,
|
||||
bool RequiresControl,
|
||||
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
|
||||
public sealed record ResolveTemplateRouteQuery(
|
||||
Guid RequestTypeId,
|
||||
string? UserEmail,
|
||||
Guid? ProjectId,
|
||||
RouteStepStatus Status) : IRequest<Result<ResolveTemplateRouteResultDto>>;
|
||||
|
||||
public sealed class ResolveTemplateRouteHandler(
|
||||
ApprovalRouteDbContext db,
|
||||
HttpTnsuPortalClient portalClient,
|
||||
ICurrentUser currentUser)
|
||||
: IRequestHandler<ResolveTemplateRouteQuery, Result<ResolveTemplateRouteResultDto>>
|
||||
{
|
||||
public async Task<Result<ResolveTemplateRouteResultDto>> Handle(ResolveTemplateRouteQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Status == RouteStepStatus.None)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||
new ValidationError("Статус обязателен: Approval или Execution."));
|
||||
|
||||
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
|
||||
|
||||
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken);
|
||||
if (!requestTypeExists)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||
|
||||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||
var byProject = request.ProjectId is not null;
|
||||
|
||||
var route = await db.TemplateRoutes.AsNoTracking()
|
||||
.Include(x => x.Steps)
|
||||
.Where(x =>
|
||||
x.RequestTypeId == request.RequestTypeId &&
|
||||
x.CompanyId == employee.CompanyId &&
|
||||
x.ByProject == byProject &&
|
||||
x.DepartmentId == employee.DepartmentId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? await db.TemplateRoutes.AsNoTracking()
|
||||
.Include(x => x.Steps)
|
||||
.Where(x =>
|
||||
x.RequestTypeId == request.RequestTypeId &&
|
||||
x.CompanyId == employee.CompanyId &&
|
||||
x.ByProject == byProject &&
|
||||
x.DepartmentId == null)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (route is null)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||||
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
|
||||
request.RequestTypeId));
|
||||
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
|
||||
if (request.ProjectId is { } projectId)
|
||||
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||||
|
||||
var steps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.Select(s => ResolveStep(s, projectExecutors))
|
||||
.ToList();
|
||||
|
||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps));
|
||||
}
|
||||
|
||||
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors)
|
||||
{
|
||||
// Для шагов с ролью в проекте фактический исполнитель приходит из данных проекта (api.tnsu.kz),
|
||||
// а не из статичного ExecutorEmail шаблона — тот используется только для шагов без привязки к проекту.
|
||||
if (step.ProjectRole != ProjectRole.None)
|
||||
{
|
||||
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole);
|
||||
if (projectExecutor is not null)
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id, step.OrderNo, projectExecutor.Email, projectExecutor.Position,
|
||||
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
|
||||
}
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id, step.OrderNo, step.ExecutorEmail, step.Position,
|
||||
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
|
||||
public sealed record CreateTemplateRouteCommand(CreateTemplateRouteRequest Request) : IRequest<Result<TemplateRouteDto>>;
|
||||
|
||||
public sealed record UpdateTemplateRouteCommand(Guid Id, UpdateTemplateRouteRequest Request) : IRequest<Result<TemplateRouteDto>>;
|
||||
|
||||
public sealed record DeleteTemplateRouteCommand(Guid Id) : IRequest<Result>;
|
||||
|
||||
public sealed class CreateTemplateRouteHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<CreateTemplateRouteCommand, Result<TemplateRouteDto>>
|
||||
{
|
||||
public async Task<Result<TemplateRouteDto>> Handle(CreateTemplateRouteCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var r = command.Request;
|
||||
|
||||
if (r.CompanyId == Guid.Empty)
|
||||
return Result.Fail<TemplateRouteDto>(new ValidationError("Компания обязательна."));
|
||||
|
||||
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
|
||||
if (!requestTypeExists)
|
||||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
|
||||
|
||||
var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync(
|
||||
db, r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||
|
||||
var entity = new TemplateRoute
|
||||
{
|
||||
RouteType = r.RouteType,
|
||||
RequestTypeId = r.RequestTypeId,
|
||||
DepartmentId = r.DepartmentId,
|
||||
CompanyId = r.CompanyId,
|
||||
ByProject = r.ByProject,
|
||||
RequiresControl = r.RequiresControl
|
||||
};
|
||||
db.TemplateRoutes.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(TemplateRouteMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdateTemplateRouteHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<UpdateTemplateRouteCommand, Result<TemplateRouteDto>>
|
||||
{
|
||||
public async Task<Result<TemplateRouteDto>> Handle(UpdateTemplateRouteCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", command.Id));
|
||||
|
||||
var r = command.Request;
|
||||
|
||||
if (r.CompanyId == Guid.Empty)
|
||||
return Result.Fail<TemplateRouteDto>(new ValidationError("Компания обязательна."));
|
||||
|
||||
var duplicate = await TemplateRouteUniqueness.HasDuplicateAsync(
|
||||
db, entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||
|
||||
entity.RouteType = r.RouteType;
|
||||
entity.DepartmentId = r.DepartmentId;
|
||||
entity.CompanyId = r.CompanyId;
|
||||
entity.ByProject = r.ByProject;
|
||||
entity.RequiresControl = r.RequiresControl;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(TemplateRouteMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DeleteTemplateRouteHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<DeleteTemplateRouteCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(DeleteTemplateRouteCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail(new NotFoundError("Маршрут", request.Id));
|
||||
|
||||
db.TemplateRoutes.Remove(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TemplateRouteUniqueness
|
||||
{
|
||||
public static Task<bool> HasDuplicateAsync(
|
||||
ApprovalRouteDbContext db,
|
||||
Guid requestTypeId,
|
||||
Guid? departmentId,
|
||||
Guid companyId,
|
||||
bool byProject,
|
||||
Guid? excludeId,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.TemplateRoutes.AnyAsync(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RequestTypeId == requestTypeId &&
|
||||
x.DepartmentId == departmentId &&
|
||||
x.CompanyId == companyId &&
|
||||
x.ByProject == byProject,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
internal static class TemplateRouteMapper
|
||||
{
|
||||
public static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
|
||||
public sealed record TemplateRouteDto(
|
||||
Guid Id,
|
||||
RouteType RouteType,
|
||||
Guid RequestTypeId,
|
||||
Guid? DepartmentId,
|
||||
Guid CompanyId,
|
||||
bool ByProject,
|
||||
bool RequiresControl);
|
||||
|
||||
public sealed record CreateTemplateRouteRequest(
|
||||
RouteType RouteType,
|
||||
Guid RequestTypeId,
|
||||
Guid? DepartmentId,
|
||||
Guid CompanyId,
|
||||
bool ByProject,
|
||||
bool RequiresControl);
|
||||
|
||||
public sealed record UpdateTemplateRouteRequest(
|
||||
RouteType RouteType,
|
||||
Guid? DepartmentId,
|
||||
Guid CompanyId,
|
||||
bool ByProject,
|
||||
bool RequiresControl);
|
||||
@@ -0,0 +1,43 @@
|
||||
using FluentResults;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
|
||||
public sealed record GetTemplateRouteQuery(Guid Id) : IRequest<Result<TemplateRouteDto>>;
|
||||
|
||||
public sealed record ListTemplateRoutesQuery(Guid? RequestTypeId) : IRequest<Result<IReadOnlyList<TemplateRouteDto>>>;
|
||||
|
||||
public sealed class GetTemplateRouteHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<GetTemplateRouteQuery, Result<TemplateRouteDto>>
|
||||
{
|
||||
public async Task<Result<TemplateRouteDto>> Handle(GetTemplateRouteQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRoutes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken);
|
||||
|
||||
return entity is null
|
||||
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", request.Id))
|
||||
: Result.Ok(TemplateRouteMapper.ToDto(entity));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ListTemplateRoutesHandler(ApprovalRouteDbContext db)
|
||||
: IRequestHandler<ListTemplateRoutesQuery, Result<IReadOnlyList<TemplateRouteDto>>>
|
||||
{
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> Handle(ListTemplateRoutesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
|
||||
if (request.RequestTypeId is { } requestTypeId)
|
||||
query = query.Where(x => x.RequestTypeId == requestTypeId);
|
||||
|
||||
var items = await query
|
||||
.Select(x => new TemplateRouteDto(
|
||||
x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentResults" />
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -9,5 +9,5 @@ public class RequestType
|
||||
public string Name { 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>();
|
||||
}
|
||||
|
||||
+3
-3
@@ -2,19 +2,19 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||
|
||||
public class Route
|
||||
public class TemplateRoute
|
||||
{
|
||||
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? 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>();
|
||||
public ICollection<TemplateRouteStep> Steps { get; set; } = new List<TemplateRouteStep>();
|
||||
}
|
||||
+4
-3
@@ -2,7 +2,7 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||
|
||||
public class RouteStep
|
||||
public class TemplateRouteStep
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public int OrderNo { get; set; }
|
||||
@@ -11,10 +11,11 @@ public class RouteStep
|
||||
public string ExecutorEmail { get; set; } = string.Empty;
|
||||
public string Position { get; set; } = string.Empty;
|
||||
public ProjectRole ProjectRole { get; set; } = ProjectRole.None;
|
||||
public HeadType HeadType { get; set; } = HeadType.None;
|
||||
|
||||
public RouteStepStatus Status { get; set; } = RouteStepStatus.Draft;
|
||||
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
|
||||
public Guid RouteId { get; set; }
|
||||
public bool AllowReassignToOtherApprovers { get; set; }
|
||||
|
||||
public Route? Route { get; set; }
|
||||
public TemplateRoute? Route { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
public enum HeadType
|
||||
{
|
||||
None,
|
||||
Chief,
|
||||
DirectionHead
|
||||
}
|
||||
@@ -3,11 +3,6 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||
public enum RouteStepStatus
|
||||
{
|
||||
None,
|
||||
Draft,
|
||||
OnApproval,
|
||||
Approved,
|
||||
InExecution,
|
||||
Completed,
|
||||
Returned,
|
||||
Closed
|
||||
Approval,
|
||||
Execution
|
||||
}
|
||||
|
||||
+10
@@ -2,7 +2,9 @@ using Extensions.Hosting.AsyncInitialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure;
|
||||
|
||||
@@ -16,6 +18,14 @@ public static class InfrastructureServiceCollectionExtensions
|
||||
.UseSnakeCaseNamingConvention());
|
||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||
|
||||
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||
services.AddHttpClient<HttpTnsuPortalClient>((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||
public DbSet<Route> Routes => Set<Route>();
|
||||
public DbSet<RouteStep> RouteSteps => Set<RouteStep>();
|
||||
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -18,19 +18,21 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
||||
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Route>(e =>
|
||||
modelBuilder.Entity<TemplateRoute>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
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.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired();
|
||||
e.Property(x => x.Position).HasMaxLength(128).IsRequired();
|
||||
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||
e.Property(x => x.HeadType).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.HasIndex(x => new { x.RouteId, x.OrderNo });
|
||||
|
||||
+24
-17
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||
[Migration("20260722130455_InitialCreate")]
|
||||
[Migration("20260723074943_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -50,7 +50,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
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")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -65,7 +65,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("company_id");
|
||||
|
||||
b.Property<Guid>("DepartmentId")
|
||||
b.Property<Guid?>("DepartmentId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("department_id");
|
||||
|
||||
@@ -84,15 +84,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("route_type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_routes");
|
||||
.HasName("pk_template_routes");
|
||||
|
||||
b.HasIndex("RequestTypeId")
|
||||
.HasDatabaseName("ix_routes_request_type_id");
|
||||
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||
.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")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -109,6 +110,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("executor_email");
|
||||
|
||||
b.Property<string>("HeadType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("head_type");
|
||||
|
||||
b.Property<int>("OrderNo")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("order_no");
|
||||
@@ -136,34 +143,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_route_steps");
|
||||
.HasName("pk_template_route_steps");
|
||||
|
||||
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")
|
||||
.WithMany("Routes")
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_routes_request_types_request_type_id");
|
||||
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||
|
||||
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")
|
||||
.HasForeignKey("RouteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_route_steps_routes_route_id");
|
||||
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||
|
||||
b.Navigation("Route");
|
||||
});
|
||||
@@ -173,7 +180,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Routes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
+17
-15
@@ -25,22 +25,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "routes",
|
||||
name: "template_routes",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", 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),
|
||||
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),
|
||||
by_project = table.Column<bool>(type: "boolean", nullable: false),
|
||||
requires_control = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_routes", x => x.id);
|
||||
table.PrimaryKey("pk_template_routes", x => x.id);
|
||||
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,
|
||||
principalTable: "request_types",
|
||||
principalColumn: "id",
|
||||
@@ -48,7 +48,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "route_steps",
|
||||
name: "template_route_steps",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
@@ -56,40 +56,42 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
head_type = 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),
|
||||
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_route_steps", x => x.id);
|
||||
table.PrimaryKey("pk_template_route_steps", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_route_steps_routes_route_id",
|
||||
name: "fk_template_route_steps_template_routes_route_id",
|
||||
column: x => x.route_id,
|
||||
principalTable: "routes",
|
||||
principalTable: "template_routes",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_route_steps_route_id_order_no",
|
||||
table: "route_steps",
|
||||
name: "ix_template_route_steps_route_id_order_no",
|
||||
table: "template_route_steps",
|
||||
columns: new[] { "route_id", "order_no" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_routes_request_type_id",
|
||||
table: "routes",
|
||||
column: "request_type_id");
|
||||
name: "ix_template_routes_request_type_id_department_id_company_id_by",
|
||||
table: "template_routes",
|
||||
columns: new[] { "request_type_id", "department_id", "company_id", "by_project" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "route_steps");
|
||||
name: "template_route_steps");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "routes");
|
||||
name: "template_routes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "request_types");
|
||||
+23
-16
@@ -47,7 +47,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
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")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -62,7 +62,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("company_id");
|
||||
|
||||
b.Property<Guid>("DepartmentId")
|
||||
b.Property<Guid?>("DepartmentId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("department_id");
|
||||
|
||||
@@ -81,15 +81,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("route_type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_routes");
|
||||
.HasName("pk_template_routes");
|
||||
|
||||
b.HasIndex("RequestTypeId")
|
||||
.HasDatabaseName("ix_routes_request_type_id");
|
||||
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||
.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")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -106,6 +107,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("executor_email");
|
||||
|
||||
b.Property<string>("HeadType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("head_type");
|
||||
|
||||
b.Property<int>("OrderNo")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("order_no");
|
||||
@@ -133,34 +140,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_route_steps");
|
||||
.HasName("pk_template_route_steps");
|
||||
|
||||
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")
|
||||
.WithMany("Routes")
|
||||
.HasForeignKey("RequestTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_routes_request_types_request_type_id");
|
||||
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||
|
||||
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")
|
||||
.HasForeignKey("RouteId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_route_steps_routes_route_id");
|
||||
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||
|
||||
b.Navigation("Route");
|
||||
});
|
||||
@@ -170,7 +177,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||
b.Navigation("Routes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -12,6 +11,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
// Клиент api.tnsu.kz (api-tnsu-portal). GetEmployeeByEmailAsync и GetProjectExecutorsAsync — заглушки:
|
||||
// на момент написания у api-tnsu-portal нет эндпоинта GET /Employee/ByEmail (есть только ByIIN/Manager),
|
||||
// а форма ответа GET /Project/{oid} в его openapi-спеке не типизирована. Заменить на реальные вызовы,
|
||||
// когда эндпоинты будут готовы — токен уже прокидывается через TnsuPortalTokenProvider.
|
||||
public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider)
|
||||
{
|
||||
public Task<EmployeeDirectoryInfo> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new EmployeeDirectoryInfo(
|
||||
DepartmentId: Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||
CompanyId: Guid.Parse("00000000-0000-0000-0000-000000000002")));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ProjectExecutorInfo>>([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
public sealed record EmployeeDirectoryInfo(Guid DepartmentId, Guid CompanyId);
|
||||
|
||||
public sealed record ProjectExecutorInfo(string Email, string Position, ProjectRole ProjectRole);
|
||||
@@ -0,0 +1,10 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя.
|
||||
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);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user