From 1a5ed34182bfa7ada572a7519ec18558d762c9ae Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 23 Jul 2026 13:55:53 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D1=80=D1=83=D0=B4=20=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2-?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Directory.Packages.props | 1 + .../Common/ResultExtensions.cs | 33 +++++ .../Controllers/RequestTypesController.cs | 31 ++++ .../TemplateRouteStepsController.cs | 31 ++++ .../Controllers/TemplateRoutesController.cs | 31 ++++ backend/src/Tnsu.ApprovalRoute.Api/Program.cs | 4 + .../Common/Errors/ApiErrors.cs | 12 ++ .../RequestTypes/RequestTypeCommands.cs | 75 ++++++++++ .../RequestTypes/RequestTypeDto.cs | 7 + .../RequestTypes/RequestTypeQueries.cs | 39 +++++ .../TemplateRouteStepCommands.cs | 134 ++++++++++++++++++ .../TemplateRouteStepDto.cs | 32 +++++ .../TemplateRouteStepQueries.cs | 41 ++++++ .../TemplateRoutes/TemplateRouteCommands.cs | 122 ++++++++++++++++ .../TemplateRoutes/TemplateRouteDto.cs | 27 ++++ .../TemplateRoutes/TemplateRouteQueries.cs | 43 ++++++ .../Tnsu.ApprovalRoute.Application.csproj | 3 + .../Tnsu.ApprovalRoute.Infrastructure.csproj | 1 - 18 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 0217988..30eec15 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..f440b19 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs @@ -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(this Result result) + { + if (result.IsFailed) + return result.Errors[0].ToActionResult(); + + var warnings = result.Reasons.OfType().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 } + }; +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs new file mode 100644 index 0000000..5dac9c2 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.RequestTypes; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +[ApiController] +[Route("api/request-types")] +public sealed class RequestTypesController(ISender mediator) : ControllerBase +{ + [HttpGet] + public async Task List(CancellationToken ct) => + (await mediator.Send(new ListRequestTypesQuery(), ct)).ToActionResult(); + + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) => + (await mediator.Send(new GetRequestTypeQuery(id), ct)).ToActionResult(); + + [HttpPost] + public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) => + (await mediator.Send(new CreateRequestTypeCommand(body.Name, body.PageUrl), ct)).ToActionResult(); + + [HttpPut("{id:guid}")] + public async Task 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 Delete(Guid id, CancellationToken ct) => + (await mediator.Send(new DeleteRequestTypeCommand(id), ct)).ToActionResult(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs new file mode 100644 index 0000000..5c2b198 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +[ApiController] +[Route("api/template-routes/{routeId:guid}/steps")] +public sealed class TemplateRouteStepsController(ISender mediator) : ControllerBase +{ + [HttpGet] + public async Task List(Guid routeId, CancellationToken ct) => + (await mediator.Send(new ListTemplateRouteStepsQuery(routeId), ct)).ToActionResult(); + + [HttpGet("{id:guid}")] + public async Task Get(Guid routeId, Guid id, CancellationToken ct) => + (await mediator.Send(new GetTemplateRouteStepQuery(routeId, id), ct)).ToActionResult(); + + [HttpPost] + public async Task Create(Guid routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) => + (await mediator.Send(new CreateTemplateRouteStepCommand(routeId, body), ct)).ToActionResult(); + + [HttpPut("{id:guid}")] + public async Task 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 Delete(Guid routeId, Guid id, CancellationToken ct) => + (await mediator.Send(new DeleteTemplateRouteStepCommand(routeId, id), ct)).ToActionResult(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs new file mode 100644 index 0000000..9d26b2e --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -0,0 +1,31 @@ +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Tnsu.ApprovalRoute.Api.Common; +using Tnsu.ApprovalRoute.Application.TemplateRoutes; + +namespace Tnsu.ApprovalRoute.Api.Controllers; + +[ApiController] +[Route("api/template-routes")] +public sealed class TemplateRoutesController(ISender mediator) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery] Guid? requestTypeId, CancellationToken ct) => + (await mediator.Send(new ListTemplateRoutesQuery(requestTypeId), ct)).ToActionResult(); + + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) => + (await mediator.Send(new GetTemplateRouteQuery(id), ct)).ToActionResult(); + + [HttpPost] + public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) => + (await mediator.Send(new CreateTemplateRouteCommand(body), ct)).ToActionResult(); + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) => + (await mediator.Send(new UpdateTemplateRouteCommand(id, body), ct)).ToActionResult(); + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) => + (await mediator.Send(new DeleteTemplateRouteCommand(id), ct)).ToActionResult(); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index 5d1223b..e9793c2 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Extensions.Hosting.AsyncInitialization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +12,8 @@ builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.AddOpenApi(); builder.Services.AddHealthChecks(); +builder.Services.AddControllers() + .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); @@ -18,6 +21,7 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.MapHealthChecks("/health"); +app.MapControllers(); await app.InitAndRunAsync(); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs new file mode 100644 index 0000000..424fdef --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/Common/Errors/ApiErrors.cs @@ -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); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs new file mode 100644 index 0000000..a2449cb --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeCommands.cs @@ -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>; + +public sealed record UpdateRequestTypeCommand(Guid Id, string Name, string PageUrl) : IRequest>; + +public sealed record DeleteRequestTypeCommand(Guid Id) : IRequest; + +public sealed class CreateRequestTypeHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateRequestTypeCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); + if (string.IsNullOrWhiteSpace(request.PageUrl)) + return Result.Fail(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> +{ + public async Task> Handle(UpdateRequestTypeCommand 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)); + + if (string.IsNullOrWhiteSpace(request.Name)) + return Result.Fail(new ValidationError("Имя типа заявки обязательно.")); + if (string.IsNullOrWhiteSpace(request.PageUrl)) + return Result.Fail(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 +{ + public async Task 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); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs new file mode 100644 index 0000000..fe74c5b --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -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); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs new file mode 100644 index 0000000..93b75cd --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeQueries.cs @@ -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>; + +public sealed record ListRequestTypesQuery : IRequest>>; + +public sealed class GetRequestTypeHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetRequestTypeQuery request, CancellationToken cancellationToken) + { + var entity = await db.RequestTypes.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Тип заявки", request.Id)) + : Result.Ok(RequestTypeMapper.ToDto(entity)); + } +} + +public sealed class ListRequestTypesHandler(ApprovalRouteDbContext db) + : IRequestHandler>> +{ + public async Task>> 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>(items); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs new file mode 100644 index 0000000..fa858a8 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepCommands.cs @@ -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>; + +public sealed record UpdateTemplateRouteStepCommand(Guid RouteId, Guid Id, UpdateTemplateRouteStepRequest Request) + : IRequest>; + +public sealed record DeleteTemplateRouteStepCommand(Guid RouteId, Guid Id) : IRequest; + +public sealed class CreateTemplateRouteStepHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateTemplateRouteStepCommand command, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken); + if (route is null) + return Result.Fail(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(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> +{ + public async Task> Handle(UpdateTemplateRouteStepCommand command, CancellationToken cancellationToken) + { + var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.RouteId, cancellationToken); + if (route is null) + return Result.Fail(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(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(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 +{ + public async Task 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> AppendMissingProjectRoleWarningAsync( + ApprovalRouteDbContext db, TemplateRoute route, Result 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); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs new file mode 100644 index 0000000..0a5f852 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -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); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs new file mode 100644 index 0000000..2ddce86 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepQueries.cs @@ -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>; + +public sealed record ListTemplateRouteStepsQuery(Guid RouteId) : IRequest>>; + +public sealed class GetTemplateRouteStepHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> 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(new NotFoundError("Шаг маршрута", request.Id)) + : Result.Ok(TemplateRouteStepMapper.ToDto(entity)); + } +} + +public sealed class ListTemplateRouteStepsHandler(ApprovalRouteDbContext db) + : IRequestHandler>> +{ + public async Task>> 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>(items); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs new file mode 100644 index 0000000..19c54a0 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteCommands.cs @@ -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>; + +public sealed record UpdateTemplateRouteCommand(Guid Id, UpdateTemplateRouteRequest Request) : IRequest>; + +public sealed record DeleteTemplateRouteCommand(Guid Id) : IRequest; + +public sealed class CreateTemplateRouteHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(CreateTemplateRouteCommand command, CancellationToken cancellationToken) + { + var r = command.Request; + + if (r.CompanyId == Guid.Empty) + return Result.Fail(new ValidationError("Компания обязательна.")); + + var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken); + if (!requestTypeExists) + return Result.Fail(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(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> +{ + public async Task> Handle(UpdateTemplateRouteCommand command, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == command.Id, cancellationToken); + if (entity is null) + return Result.Fail(new NotFoundError("Маршрут", command.Id)); + + var r = command.Request; + + if (r.CompanyId == Guid.Empty) + return Result.Fail(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(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 +{ + public async Task 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 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); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs new file mode 100644 index 0000000..7f7a518 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -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); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs new file mode 100644 index 0000000..54e417a --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteQueries.cs @@ -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>; + +public sealed record ListTemplateRoutesQuery(Guid? RequestTypeId) : IRequest>>; + +public sealed class GetTemplateRouteHandler(ApprovalRouteDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetTemplateRouteQuery request, CancellationToken cancellationToken) + { + var entity = await db.TemplateRoutes.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken); + + return entity is null + ? Result.Fail(new NotFoundError("Маршрут", request.Id)) + : Result.Ok(TemplateRouteMapper.ToDto(entity)); + } +} + +public sealed class ListTemplateRoutesHandler(ApprovalRouteDbContext db) + : IRequestHandler>> +{ + public async Task>> 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>(items); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj index 60bfbd4..409b6fe 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Application/Tnsu.ApprovalRoute.Application.csproj @@ -1,8 +1,11 @@ + + + diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj index 79b63af..b5db8c5 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Tnsu.ApprovalRoute.Infrastructure.csproj @@ -1,6 +1,5 @@ -