7 Commits

Author SHA1 Message Date
gorbenko.pavel 3ae1965c29 перешли на гуид
Tests / test (pull_request) Successful in 1m41s
2026-07-24 16:53:35 +05:00
gorbenko.pavel a125ed2834 передача токена 2026-07-24 15:33:54 +05:00
gorbenko.pavel 40e7974244 DirectorTansu 2026-07-24 14:27:21 +05:00
gorbenko.pavel 00f00a4c3a теперь массив ошибок 2026-07-24 14:18:16 +05:00
gorbenko.pavel 06cab46efc warnings из общего тела убрано 2026-07-24 14:05:37 +05:00
gorbenko.pavel 5e3ef814f3 перешли на int 2026-07-24 13:57:30 +05:00
gorbenko.pavel 5e45c18817 enum в api строки 2026-07-24 12:58:59 +05:00
32 changed files with 227 additions and 203 deletions
@@ -1,5 +1,5 @@
namespace Tnsu.ApprovalRoute.Api.Common; namespace Tnsu.ApprovalRoute.Api.Common;
public sealed record ApiResponse<T>(T Data, IReadOnlyList<string> Warnings); public sealed record ApiResponse<T>(T Data);
public sealed record ApiError(string Message); public sealed record ApiError(IReadOnlyList<string> Messages);
@@ -11,8 +11,7 @@ public static class ResultExtensions
if (result.IsFailed) if (result.IsFailed)
return result.Errors[0].ToActionResult(); return result.Errors[0].ToActionResult();
var warnings = result.Reasons.OfType<Warning>().Select(w => w.Message).ToList(); return new OkObjectResult(new ApiResponse<T>(result.Value));
return new OkObjectResult(new ApiResponse<T>(result.Value, warnings));
} }
public static IActionResult ToActionResult(this Result result) public static IActionResult ToActionResult(this Result result)
@@ -25,9 +24,9 @@ public static class ResultExtensions
private static IActionResult ToActionResult(this IError error) => error switch private static IActionResult ToActionResult(this IError error) => error switch
{ {
NotFoundError => new NotFoundObjectResult(new ApiError(error.Message)), NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])),
ValidationError => new BadRequestObjectResult(new ApiError(error.Message)), ValidationError => new BadRequestObjectResult(new ApiError([error.Message])),
ConflictError => new ConflictObjectResult(new ApiError(error.Message)), ConflictError => new ConflictObjectResult(new ApiError([error.Message])),
_ => new ObjectResult(new ApiError(error.Message)) { StatusCode = StatusCodes.Status500InternalServerError } _ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError }
}; };
} }
@@ -16,10 +16,10 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
public async Task<IActionResult> List(CancellationToken ct) => public async Task<IActionResult> List(CancellationToken ct) =>
(await service.ListAsync(ct)).ToActionResult(); (await service.ListAsync(ct)).ToActionResult();
[HttpGet("{id:guid}")] [HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id, CancellationToken ct) => public async Task<IActionResult> Get(int id, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToActionResult(); (await service.GetAsync(id, ct)).ToActionResult();
[HttpPost] [HttpPost]
@@ -28,16 +28,16 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) => public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult(); (await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:guid}")] [HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, UpdateRequestTypeRequest body, CancellationToken ct) => public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult(); (await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) => public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
(await service.DeleteAsync(id, ct)).ToActionResult(); (await service.DeleteAsync(id, ct)).ToActionResult();
} }
@@ -7,38 +7,38 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("api/template-routes/{routeId:guid}/steps")] [Route("api/template-routes/{routeId:int}/steps")]
[Produces("application/json")] [Produces("application/json")]
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(Guid routeId, CancellationToken ct) => public async Task<IActionResult> List(int routeId, CancellationToken ct) =>
(await service.ListAsync(routeId, ct)).ToActionResult(); (await service.ListAsync(routeId, ct)).ToActionResult();
[HttpGet("{id:guid}")] [HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid routeId, Guid id, CancellationToken ct) => public async Task<IActionResult> Get(int routeId, int id, CancellationToken ct) =>
(await service.GetAsync(routeId, id, ct)).ToActionResult(); (await service.GetAsync(routeId, id, ct)).ToActionResult();
[HttpPost] [HttpPost]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => public async Task<IActionResult> Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.CreateAsync(routeId, body, ct)).ToActionResult(); (await service.CreateAsync(routeId, body, ct)).ToActionResult();
[HttpPut("{id:guid}")] [HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid routeId, Guid id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => public async Task<IActionResult> Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid routeId, Guid id, CancellationToken ct) => public async Task<IActionResult> Delete(int routeId, int id, CancellationToken ct) =>
(await service.DeleteAsync(routeId, id, ct)).ToActionResult(); (await service.DeleteAsync(routeId, id, ct)).ToActionResult();
} }
@@ -20,13 +20,13 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] Guid? requestTypeId, CancellationToken ct) => public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
(await service.ListAsync(requestTypeId, ct)).ToActionResult(); (await service.ListAsync(requestTypeId, ct)).ToActionResult();
[HttpGet("{id:guid}")] [HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(Guid id, CancellationToken ct) => public async Task<IActionResult> Get(int id, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToActionResult(); (await service.GetAsync(id, ct)).ToActionResult();
[HttpPost] [HttpPost]
@@ -37,17 +37,17 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) => public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult(); (await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:guid}")] [HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => public async Task<IActionResult> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult(); (await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) => public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
(await service.DeleteAsync(id, ct)).ToActionResult(); (await service.DeleteAsync(id, ct)).ToActionResult();
} }
@@ -25,7 +25,7 @@ public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : I
continue; continue;
context.Result = new BadRequestObjectResult( context.Result = new BadRequestObjectResult(
new ApiError(string.Join(" ", result.Errors.Select(e => e.ErrorMessage)))); new ApiError(result.Errors.Select(e => e.ErrorMessage).ToList()));
return; return;
} }
@@ -16,7 +16,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication(); builder.Services.AddApprovalRouteApplication();
builder.Services.AddApprovalRouteAuth(builder.Configuration); builder.Services.AddApprovalRouteAuth(builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>(); builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
builder.Services.AddOpenApi(options => builder.Services.AddOpenApi(options =>
{ {
@@ -48,6 +47,8 @@ builder.Services.AddOpenApi(options =>
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>()) builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Тот же конвертер для Http.Json — их читает генератор OpenAPI, чтобы enum в схеме были строками, а не числами.
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var app = builder.Build(); var app = builder.Build();
@@ -9,8 +9,8 @@
"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": { "Entra": {
"TenantId": "", "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"Audience": "" "Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
}, },
"Dev": { "Dev": {
"DefaultUserEmail": "dev@tnsu.kz" "DefaultUserEmail": "dev@tnsu.kz"
@@ -2,11 +2,9 @@ using FluentResults;
namespace Tnsu.ApprovalRoute.Application.Common.Errors; namespace Tnsu.ApprovalRoute.Application.Common.Errors;
public sealed class NotFoundError(string entity, Guid id) public sealed class NotFoundError(string entity, object id)
: Error($"{entity} с id {id} не найден."); : Error($"{entity} с id {id} не найден.");
public sealed class ValidationError(string message) : Error(message); public sealed class ValidationError(string message) : Error(message);
public sealed class ConflictError(string message) : Error(message); public sealed class ConflictError(string message) : Error(message);
public sealed class Warning(string message) : Success(message);
@@ -1,6 +1,6 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes; namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed record RequestTypeDto(Guid Id, string Name, string PageUrl); public sealed record RequestTypeDto(int Id, string Name, string PageUrl);
public sealed record CreateRequestTypeRequest(string Name, string PageUrl); public sealed record CreateRequestTypeRequest(string Name, string PageUrl);
@@ -18,7 +18,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items); return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
} }
public async Task<Result<RequestTypeDto>> GetAsync(Guid id, CancellationToken cancellationToken) public async Task<Result<RequestTypeDto>> GetAsync(int id, CancellationToken cancellationToken)
{ {
var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -36,7 +36,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
} }
public async Task<Result<RequestTypeDto>> UpdateAsync(Guid id, UpdateRequestTypeRequest request, CancellationToken cancellationToken) public async Task<Result<RequestTypeDto>> UpdateAsync(int id, UpdateRequestTypeRequest request, CancellationToken cancellationToken)
{ {
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) if (entity is null)
@@ -49,7 +49,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
} }
public async Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken) public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{ {
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) if (entity is null)
@@ -3,15 +3,16 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps; namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed record TemplateRouteStepDto( public sealed record TemplateRouteStepDto(
Guid Id, int Id,
int OrderNo, int OrderNo,
string ExecutorEmail, string ExecutorEmail,
string Position, string Position,
ProjectRole ProjectRole, ProjectRole ProjectRole,
HeadType HeadType, HeadType HeadType,
RouteStepStatus Status, RouteStepStatus Status,
Guid RouteId, int RouteId,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers,
string? Warning = null);
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -9,7 +9,7 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
{ {
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(Guid routeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{ {
var items = await db.TemplateRouteSteps.AsNoTracking() var items = await db.TemplateRouteSteps.AsNoTracking()
.Where(x => x.RouteId == routeId) .Where(x => x.RouteId == routeId)
@@ -21,7 +21,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
} }
public async Task<Result<TemplateRouteStepDto>> GetAsync(Guid routeId, Guid id, CancellationToken cancellationToken) public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRouteSteps.AsNoTracking() var entity = await db.TemplateRouteSteps.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
@@ -31,7 +31,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
: Result.Ok(ToDto(entity)); : Result.Ok(ToDto(entity));
} }
public async Task<Result<TemplateRouteStepDto>> CreateAsync(Guid routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{ {
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null) if (route is null)
@@ -55,12 +55,11 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
db.TemplateRouteSteps.Add(entity); db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
var result = Result.Ok(ToDto(entity)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken);
} }
public async Task<Result<TemplateRouteStepDto>> UpdateAsync( public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
Guid routeId, Guid id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{ {
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null) if (route is null)
@@ -83,11 +82,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
var result = Result.Ok(ToDto(entity)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken);
} }
public async Task<Result> DeleteAsync(Guid routeId, Guid id, CancellationToken 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); var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null) if (entity is null)
@@ -99,18 +97,20 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok(); return Result.Ok();
} }
private async Task<Result<TemplateRouteStepDto>> AppendMissingProjectRoleWarningAsync( // Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning.
TemplateRoute route, Result<TemplateRouteStepDto> result, CancellationToken cancellationToken) private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{ {
if (!route.ByProject) string? warning = null;
return result; if (route.ByProject)
{
var hasProjectRoleStep = await db.TemplateRouteSteps
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
if (!hasProjectRoleStep)
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
}
var hasProjectRoleStep = await db.TemplateRouteSteps return ToDto(entity) with { Warning = warning };
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
return hasProjectRoleStep
? result
: result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте."));
} }
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
@@ -3,7 +3,7 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolvedRouteStepDto( public sealed record ResolvedRouteStepDto(
Guid Id, int Id,
int OrderNo, int OrderNo,
string ExecutorEmail, string ExecutorEmail,
string Position, string Position,
@@ -13,13 +13,13 @@ public sealed record ResolvedRouteStepDto(
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers);
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
Guid RouteId, int RouteId,
RouteType RouteType, RouteType RouteType,
bool RequiresControl, bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps); IReadOnlyList<ResolvedRouteStepDto> Steps);
public sealed record ResolveTemplateRouteRequest( public sealed record ResolveTemplateRouteRequest(
Guid RequestTypeId, int RequestTypeId,
string? UserEmail, string? UserEmail,
Guid? ProjectId, Guid? ProjectId,
RouteStepStatus Status); RouteStepStatus Status);
@@ -3,25 +3,25 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record TemplateRouteDto( public sealed record TemplateRouteDto(
Guid Id, int Id,
RouteType RouteType, RouteType RouteType,
Guid RequestTypeId, int RequestTypeId,
string? Department, Guid? DepartmentId,
string Company, Guid CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record CreateTemplateRouteRequest( public sealed record CreateTemplateRouteRequest(
RouteType RouteType, RouteType RouteType,
Guid RequestTypeId, int RequestTypeId,
string? Department, Guid? DepartmentId,
string Company, Guid CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record UpdateTemplateRouteRequest( public sealed record UpdateTemplateRouteRequest(
RouteType RouteType, RouteType RouteType,
string? Department, Guid? DepartmentId,
string Company, Guid CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
@@ -14,7 +14,7 @@ public sealed class TemplateRouteService(
ITnsuPortalClient portalClient, ITnsuPortalClient portalClient,
ICurrentUser currentUser) ICurrentUser currentUser)
{ {
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(Guid? requestTypeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
{ {
var query = db.TemplateRoutes.AsNoTracking().AsQueryable(); var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
if (requestTypeId is { } id) if (requestTypeId is { } id)
@@ -22,13 +22,13 @@ public sealed class TemplateRouteService(
var items = await query var items = await query
.Select(x => new TemplateRouteDto( .Select(x => new TemplateRouteDto(
x.Id, x.RouteType, x.RequestTypeId, x.Department, x.Company, x.ByProject, x.RequiresControl)) x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
} }
public async Task<Result<TemplateRouteDto>> GetAsync(Guid id, CancellationToken cancellationToken) public async Task<Result<TemplateRouteDto>> GetAsync(int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -43,8 +43,7 @@ public sealed class TemplateRouteService(
if (!requestTypeExists) if (!requestTypeExists)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId)); return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
var department = Normalize(r.Department); var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
var duplicate = await HasDuplicateAsync(r.RequestTypeId, department, r.Company, r.ByProject, excludeId: null, cancellationToken);
if (duplicate) if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError( return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
@@ -53,8 +52,8 @@ public sealed class TemplateRouteService(
{ {
RouteType = r.RouteType, RouteType = r.RouteType,
RequestTypeId = r.RequestTypeId, RequestTypeId = r.RequestTypeId,
Department = department, DepartmentId = r.DepartmentId,
Company = r.Company, CompanyId = r.CompanyId,
ByProject = r.ByProject, ByProject = r.ByProject,
RequiresControl = r.RequiresControl RequiresControl = r.RequiresControl
}; };
@@ -64,21 +63,20 @@ public sealed class TemplateRouteService(
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
} }
public async Task<Result<TemplateRouteDto>> UpdateAsync(Guid id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken) public async Task<Result<TemplateRouteDto>> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) if (entity is null)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id)); return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
var department = Normalize(r.Department); var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, department, r.Company, r.ByProject, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError( return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType; entity.RouteType = r.RouteType;
entity.Department = department; entity.DepartmentId = r.DepartmentId;
entity.Company = r.Company; entity.CompanyId = r.CompanyId;
entity.ByProject = r.ByProject; entity.ByProject = r.ByProject;
entity.RequiresControl = r.RequiresControl; entity.RequiresControl = r.RequiresControl;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -86,7 +84,7 @@ public sealed class TemplateRouteService(
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
} }
public async Task<Result> DeleteAsync(Guid id, CancellationToken cancellationToken) public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) if (entity is null)
@@ -113,26 +111,25 @@ public sealed class TemplateRouteService(
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken); var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employee is null) if (employee is null)
return Result.Fail<ResolveTemplateRouteResultDto>( return Result.Fail<ResolveTemplateRouteResultDto>(
new NotFoundError("Сотрудник по email в справочнике api.tnsu.kz не найден", Guid.Empty)); new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
var byProject = request.ProjectId is not null; var byProject = request.ProjectId is not null;
var department = Normalize(employee.Department);
var route = await db.TemplateRoutes.AsNoTracking() var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps) .Include(x => x.Steps)
.Where(x => .Where(x =>
x.RequestTypeId == request.RequestTypeId && x.RequestTypeId == request.RequestTypeId &&
x.Company == employee.Company && x.CompanyId == employee.CompanyId &&
x.ByProject == byProject && x.ByProject == byProject &&
x.Department == department) x.DepartmentId == employee.DepartmentId)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking() ?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps) .Include(x => x.Steps)
.Where(x => .Where(x =>
x.RequestTypeId == request.RequestTypeId && x.RequestTypeId == request.RequestTypeId &&
x.Company == employee.Company && x.CompanyId == employee.CompanyId &&
x.ByProject == byProject && x.ByProject == byProject &&
x.Department == null) x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (route is null) if (route is null)
@@ -154,12 +151,12 @@ public sealed class TemplateRouteService(
} }
private Task<bool> HasDuplicateAsync( private Task<bool> HasDuplicateAsync(
Guid requestTypeId, string? department, string company, bool byProject, Guid? excludeId, CancellationToken cancellationToken) => int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRoutes.AnyAsync(x => db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId && x.Id != excludeId &&
x.RequestTypeId == requestTypeId && x.RequestTypeId == requestTypeId &&
x.Department == department && x.DepartmentId == departmentId &&
x.Company == company && x.CompanyId == companyId &&
x.ByProject == byProject, x.ByProject == byProject,
cancellationToken); cancellationToken);
@@ -181,8 +178,6 @@ public sealed class TemplateRouteService(
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers); step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
} }
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
private static TemplateRouteDto ToDto(TemplateRoute e) => new( private static TemplateRouteDto ToDto(TemplateRoute e) => new(
e.Id, e.RouteType, e.RequestTypeId, e.Department, e.Company, e.ByProject, e.RequiresControl); e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
} }
@@ -7,7 +7,7 @@ public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<Crea
{ {
public CreateTemplateRouteRequestValidator() public CreateTemplateRouteRequestValidator()
{ {
RuleFor(x => x.Company).NotEmpty().WithMessage("Компания обязательна."); RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
} }
} }
@@ -15,7 +15,7 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<Upda
{ {
public UpdateTemplateRouteRequestValidator() public UpdateTemplateRouteRequestValidator()
{ {
RuleFor(x => x.Company).NotEmpty().WithMessage("Компания обязательна."); RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
} }
} }
@@ -1,11 +1,8 @@
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;
@@ -4,14 +4,14 @@ namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRoute public class TemplateRoute
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public int Id { get; set; }
public RouteType RouteType { get; set; } = RouteType.Sequential; public RouteType RouteType { get; set; } = RouteType.Sequential;
public Guid RequestTypeId { get; set; } public int RequestTypeId { get; set; }
// Компания и отдел инициатора — названия из справочника AD (api-tnsu-portal /Employee/ByEmail), // Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД.
// хранятся строками без FK в этой БД. Отдел опционален, компания обязательна. // Отдел опционален, компания обязательна.
public string? Department { get; set; } public Guid? DepartmentId { get; set; }
public string Company { get; set; } = string.Empty; public Guid CompanyId { get; set; }
public bool ByProject { get; set; } public bool ByProject { get; set; }
public bool RequiresControl { get; set; } public bool RequiresControl { get; set; }
@@ -4,7 +4,7 @@ namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRouteStep public class TemplateRouteStep
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public int Id { get; set; }
public int OrderNo { get; set; } public int OrderNo { get; set; }
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. // Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
@@ -14,7 +14,7 @@ public class TemplateRouteStep
public HeadType HeadType { get; set; } = HeadType.None; public HeadType HeadType { get; set; } = HeadType.None;
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
public Guid RouteId { get; set; } public int RouteId { get; set; }
public bool AllowReassignToOtherApprovers { get; set; } public bool AllowReassignToOtherApprovers { get; set; }
public TemplateRoute? Route { get; set; } public TemplateRoute? Route { get; set; }
@@ -4,5 +4,6 @@ public enum HeadType
{ {
None, None,
Chief, Chief,
DirectionHead DirectionHead,
DirectorTansu
} }
@@ -13,6 +13,8 @@ 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());
@@ -22,10 +22,8 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.Company).HasMaxLength(256).IsRequired();
e.Property(x => x.Department).HasMaxLength(256);
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
e.HasIndex(x => new { x.RequestTypeId, x.Department, x.Company, x.ByProject }).IsUnique(); e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique();
}); });
modelBuilder.Entity<TemplateRouteStep>(e => modelBuilder.Entity<TemplateRouteStep>(e =>
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(ApprovalRouteDbContext))] [DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260724071302_InitialCreate")] [Migration("20260724114550_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)
@@ -52,28 +54,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", 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");
b.Property<string>("Company") b.Property<Guid>("CompanyId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("company");
b.Property<string>("Department")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("department");
b.Property<Guid>("RequestTypeId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id"); .HasColumnName("request_type_id");
b.Property<bool>("RequiresControl") b.Property<bool>("RequiresControl")
@@ -89,20 +90,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_routes"); .HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "Department", "Company", "ByProject") b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_company_by_proje"); .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null); b.ToTable("template_routes", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", 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");
@@ -135,8 +138,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.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")
@@ -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)
}, },
@@ -28,11 +30,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "template_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 = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), department_id = table.Column<Guid>(type: "uuid", nullable: true),
company = table.Column<string>(type: "character varying(256)", maxLength: 256, 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)
}, },
@@ -51,14 +54,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "template_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: false),
position = table.Column<string>(type: "character varying(128)", maxLength: 128, 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), 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), 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), 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 =>
@@ -78,9 +82,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
columns: new[] { "route_id", "order_no" }); columns: new[] { "route_id", "order_no" });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_template_routes_request_type_id_department_company_by_proje", name: "ix_template_routes_request_type_id_department_id_company_id_by",
table: "template_routes", table: "template_routes",
columns: new[] { "request_type_id", "department", "company", "by_project" }, columns: new[] { "request_type_id", "department_id", "company_id", "by_project" },
unique: true); unique: true);
} }
@@ -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)
@@ -49,28 +51,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", 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");
b.Property<string>("Company") b.Property<Guid>("CompanyId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("company");
b.Property<string>("Department")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("department");
b.Property<Guid>("RequestTypeId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id"); .HasColumnName("request_type_id");
b.Property<bool>("RequiresControl") b.Property<bool>("RequiresControl")
@@ -86,20 +87,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_routes"); .HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "Department", "Company", "ByProject") b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_company_by_proje"); .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("template_routes", (string)null); b.ToTable("template_routes", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", 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");
@@ -132,8 +135,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.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")
@@ -13,5 +13,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Http" /> <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>
@@ -1,14 +1,23 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
// Клиент api.tnsu.kz (api-tnsu-portal). Сервисный токен прокидывается через TnsuPortalTokenProvider. // Клиент api.tnsu.kz (api-tnsu-portal).
public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) : ITnsuPortalClient public sealed class HttpTnsuPortalClient(
HttpClient httpClient,
TnsuPortalTokenProvider tokenProvider,
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
{ {
// Разные эндпоинты api-tnsu-portal отдают JSON в разном регистре (Dapper-результаты — PascalCase,
// строго типизированные модели — camelCase), поэтому сопоставляем поля без учёта регистра.
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}"); using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}");
@@ -19,11 +28,11 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP
return null; return null;
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>(cancellationToken); var employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>(JsonOptions, cancellationToken);
if (employee is null) if (employee is null)
return null; return null;
return new EmployeeDirectoryInfo(employee.Company ?? string.Empty, employee.Section ?? string.Empty); return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId);
} }
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
@@ -36,7 +45,7 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP
return []; return [];
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var project = await response.Content.ReadFromJsonAsync<ProjectResponse>(cancellationToken); var project = await response.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions, cancellationToken);
if (project is null) if (project is null)
return []; return [];
@@ -56,15 +65,25 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP
executors.Add(new ProjectExecutorInfo(role, email)); executors.Add(new ProjectExecutorInfo(role, email));
} }
// ВРЕМЕННО: для TnsuPortal ещё нет своего client-credentials приложения в Entra, поэтому прокидываем
// токен текущего пользователя дальше — его aud совпадает с ClientId api-tnsu-portal, так что подходит.
// Когда появится своё приложение — вернуть на tokenProvider.GetAccessTokenAsync(...).
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) 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); var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
} }
private sealed record EmployeeResponse( private sealed record EmployeeResponse(
[property: JsonPropertyName("company")] string? Company, [property: JsonPropertyName("companyId")] Guid? CompanyId,
[property: JsonPropertyName("section")] string? Section); [property: JsonPropertyName("departmentId")] Guid? DepartmentId);
private sealed record ProjectResponse( private sealed record ProjectResponse(
[property: JsonPropertyName("projectManager")] string? ProjectManager, [property: JsonPropertyName("projectManager")] string? ProjectManager,
@@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed record EmployeeDirectoryInfo(string Company, string Department); public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId);
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
@@ -33,7 +33,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task GetAsync_UnknownId_ReturnsFailedResult() public async Task GetAsync_UnknownId_ReturnsFailedResult()
{ {
// Action // Action
var result = await _service.GetAsync(Guid.NewGuid(), CancellationToken.None); var result = await _service.GetAsync(999999, CancellationToken.None);
// Assert // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.UpdateAsync( var result = await _service.UpdateAsync(
Guid.NewGuid(), new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); 999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
// Assert // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -12,8 +12,8 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture> public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture>
{ {
private const string Company = "ТОО TANSU Construction"; private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
private const string Department = "IT"; private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly ServiceProvider _services; private readonly ServiceProvider _services;
private readonly TemplateRouteService _service; private readonly TemplateRouteService _service;
@@ -24,7 +24,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(Company, Department)); .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId));
portalClient portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
@@ -34,7 +34,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
_requestTypeService = _services.GetRequiredService<RequestTypeService>(); _requestTypeService = _services.GetRequiredService<RequestTypeService>();
} }
private async Task<Guid> CreateRequestTypeAsync(string name) => private async Task<int> CreateRequestTypeAsync(string name) =>
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id;
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
@@ -45,7 +45,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: true), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -54,7 +54,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsSuccess.ShouldBeTrue(); fetched.IsSuccess.ShouldBeTrue();
fetched.Value.Company.ShouldBe(Company); fetched.Value.CompanyId.ShouldBe(CompanyId);
} }
[Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")] [Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
@@ -62,7 +62,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, Guid.NewGuid(), Department, Company, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -74,7 +74,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Business trip"); var requestTypeId = await CreateRequestTypeAsync("Business trip");
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: false); var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
var first = await _service.CreateAsync(request, CancellationToken.None); var first = await _service.CreateAsync(request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
@@ -91,13 +91,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave"); var requestTypeId = await CreateRequestTypeAsync("Sick leave");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteRequest(RouteType.Parallel, Department, Company, ByProject: false, RequiresControl: true), new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -112,7 +112,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work"); var requestTypeId = await CreateRequestTypeAsync("Remote work");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -131,7 +131,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request"); var requestTypeId = await CreateRequestTypeAsync("Exact match request");
var route = await _service.CreateAsync( var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: false, RequiresControl: true), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -158,7 +158,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request"); var requestTypeId = await CreateRequestTypeAsync("Project request");
var route = await _service.CreateAsync( var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department, Company, ByProject: true, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -184,7 +184,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange // Arrange
var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
var route = await _service.CreateAsync( var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, Department: null, Company, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -202,7 +202,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{ {
// Action // Action
var resolved = await _service.ResolveAsync( var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(Guid.NewGuid(), "someone@tnsu.kz", null, RouteStepStatus.Approval), new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -10,7 +10,7 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture> public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture>
{ {
private const string Company = "ТОО TANSU Construction"; private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
private readonly TemplateRouteStepService _service; private readonly TemplateRouteStepService _service;
private readonly TemplateRouteService _routeService; private readonly TemplateRouteService _routeService;
@@ -24,13 +24,13 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
_requestTypeService = services.GetRequiredService<RequestTypeService>(); _requestTypeService = services.GetRequiredService<RequestTypeService>();
} }
private async Task<Guid> CreateRouteAsync(string requestTypeName, bool byProject) private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject)
{ {
var requestTypeId = (await _requestTypeService.CreateAsync( var requestTypeId = (await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id;
var route = await _routeService.CreateAsync( var route = await _routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, Company, byProject, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
return route.Value.Id; return route.Value.Id;
@@ -41,7 +41,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
Guid.NewGuid(), 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
@@ -99,7 +99,8 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Assert // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
created.Reasons.ShouldContain(r => r.Message.Contains("ролью в проекте")); created.Value.Warning.ShouldNotBeNull();
created.Value.Warning.ShouldContain("ролью в проекте");
} }
[Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")] [Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")]
@@ -116,7 +117,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Assert // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
created.Reasons.ShouldBeEmpty(); created.Value.Warning.ShouldBeNull();
} }
[Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")] [Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")]