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;
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)
return result.Errors[0].ToActionResult();
var warnings = result.Reasons.OfType<Warning>().Select(w => w.Message).ToList();
return new OkObjectResult(new ApiResponse<T>(result.Value, warnings));
return new OkObjectResult(new ApiResponse<T>(result.Value));
}
public static IActionResult ToActionResult(this Result result)
@@ -25,9 +24,9 @@ public static class ResultExtensions
private static IActionResult ToActionResult(this IError error) => error switch
{
NotFoundError => new NotFoundObjectResult(new ApiError(error.Message)),
ValidationError => new BadRequestObjectResult(new ApiError(error.Message)),
ConflictError => new ConflictObjectResult(new ApiError(error.Message)),
_ => new ObjectResult(new ApiError(error.Message)) { StatusCode = StatusCodes.Status500InternalServerError }
NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])),
ValidationError => new BadRequestObjectResult(new ApiError([error.Message])),
ConflictError => new ConflictObjectResult(new ApiError([error.Message])),
_ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError }
};
}
@@ -16,10 +16,10 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
public async Task<IActionResult> List(CancellationToken ct) =>
(await service.ListAsync(ct)).ToActionResult();
[HttpGet("{id:guid}")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[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();
[HttpPost]
@@ -28,16 +28,16 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:guid}")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[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();
[HttpDelete("{id:guid}")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[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();
}
@@ -7,38 +7,38 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/template-routes/{routeId:guid}/steps")]
[Route("api/template-routes/{routeId:int}/steps")]
[Produces("application/json")]
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(Guid routeId, CancellationToken ct) =>
public async Task<IActionResult> List(int routeId, CancellationToken ct) =>
(await service.ListAsync(routeId, ct)).ToActionResult();
[HttpGet("{id:guid}")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[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();
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[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();
[HttpPut("{id:guid}")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[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();
[HttpDelete("{id:guid}")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[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();
}
@@ -20,13 +20,13 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con
[HttpGet]
[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();
[HttpGet("{id:guid}")]
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[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();
[HttpPost]
@@ -37,17 +37,17 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:guid}")]
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Update(Guid id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
public async Task<IActionResult> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:guid}")]
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[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();
}
@@ -25,7 +25,7 @@ public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : I
continue;
context.Result = new BadRequestObjectResult(
new ApiError(string.Join(" ", result.Errors.Select(e => e.ErrorMessage))));
new ApiError(result.Errors.Select(e => e.ErrorMessage).ToList()));
return;
}
@@ -16,7 +16,6 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.AddApprovalRouteAuth(builder.Configuration);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
builder.Services.AddOpenApi(options =>
{
@@ -48,6 +47,8 @@ builder.Services.AddOpenApi(options =>
builder.Services.AddHealthChecks();
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
.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();
@@ -9,8 +9,8 @@
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
},
"Entra": {
"TenantId": "",
"Audience": ""
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
},
"Dev": {
"DefaultUserEmail": "dev@tnsu.kz"
@@ -2,11 +2,9 @@ using FluentResults;
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} не найден.");
public sealed class ValidationError(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;
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);
@@ -18,7 +18,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
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);
@@ -36,7 +36,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
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);
if (entity is null)
@@ -49,7 +49,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
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);
if (entity is null)
@@ -3,15 +3,16 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed record TemplateRouteStepDto(
Guid Id,
int Id,
int OrderNo,
string ExecutorEmail,
string Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
Guid RouteId,
bool AllowReassignToOtherApprovers);
int RouteId,
bool AllowReassignToOtherApprovers,
string? Warning = null);
public sealed record CreateTemplateRouteStepRequest(
int OrderNo,
@@ -9,7 +9,7 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
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()
.Where(x => x.RouteId == routeId)
@@ -21,7 +21,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
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()
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
@@ -31,7 +31,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
: 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);
if (route is null)
@@ -55,12 +55,11 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
var result = Result.Ok(ToDto(entity));
return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
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);
if (route is null)
@@ -83,11 +82,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
await db.SaveChangesAsync(cancellationToken);
var result = Result.Ok(ToDto(entity));
return await AppendMissingProjectRoleWarningAsync(route, result, cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, 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);
if (entity is null)
@@ -99,18 +97,20 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok();
}
private async Task<Result<TemplateRouteStepDto>> AppendMissingProjectRoleWarningAsync(
TemplateRoute route, Result<TemplateRouteStepDto> result, CancellationToken cancellationToken)
// Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning.
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{
if (!route.ByProject)
return result;
string? warning = null;
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
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
return hasProjectRoleStep
? result
: result.WithReason(new Warning("Должен быть хотя бы один шаг с ролью в проекте."));
return ToDto(entity) with { Warning = warning };
}
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
@@ -3,7 +3,7 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolvedRouteStepDto(
Guid Id,
int Id,
int OrderNo,
string ExecutorEmail,
string Position,
@@ -13,13 +13,13 @@ public sealed record ResolvedRouteStepDto(
bool AllowReassignToOtherApprovers);
public sealed record ResolveTemplateRouteResultDto(
Guid RouteId,
int RouteId,
RouteType RouteType,
bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps);
public sealed record ResolveTemplateRouteRequest(
Guid RequestTypeId,
int RequestTypeId,
string? UserEmail,
Guid? ProjectId,
RouteStepStatus Status);
@@ -3,25 +3,25 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record TemplateRouteDto(
Guid Id,
int Id,
RouteType RouteType,
Guid RequestTypeId,
string? Department,
string Company,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record CreateTemplateRouteRequest(
RouteType RouteType,
Guid RequestTypeId,
string? Department,
string Company,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record UpdateTemplateRouteRequest(
RouteType RouteType,
string? Department,
string Company,
Guid? DepartmentId,
Guid CompanyId,
bool ByProject,
bool RequiresControl);
@@ -14,7 +14,7 @@ public sealed class TemplateRouteService(
ITnsuPortalClient portalClient,
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();
if (requestTypeId is { } id)
@@ -22,13 +22,13 @@ public sealed class TemplateRouteService(
var items = await query
.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);
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);
@@ -43,8 +43,7 @@ public sealed class TemplateRouteService(
if (!requestTypeExists)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
var department = Normalize(r.Department);
var duplicate = await HasDuplicateAsync(r.RequestTypeId, department, r.Company, r.ByProject, excludeId: null, cancellationToken);
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
@@ -53,8 +52,8 @@ public sealed class TemplateRouteService(
{
RouteType = r.RouteType,
RequestTypeId = r.RequestTypeId,
Department = department,
Company = r.Company,
DepartmentId = r.DepartmentId,
CompanyId = r.CompanyId,
ByProject = r.ByProject,
RequiresControl = r.RequiresControl
};
@@ -64,21 +63,20 @@ public sealed class TemplateRouteService(
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);
if (entity is null)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
var department = Normalize(r.Department);
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, department, r.Company, r.ByProject, excludeId: entity.Id, cancellationToken);
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType;
entity.Department = department;
entity.Company = r.Company;
entity.DepartmentId = r.DepartmentId;
entity.CompanyId = r.CompanyId;
entity.ByProject = r.ByProject;
entity.RequiresControl = r.RequiresControl;
await db.SaveChangesAsync(cancellationToken);
@@ -86,7 +84,7 @@ public sealed class TemplateRouteService(
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);
if (entity is null)
@@ -113,26 +111,25 @@ public sealed class TemplateRouteService(
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employee is null)
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 department = Normalize(employee.Department);
var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.Company == employee.Company &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.Department == department)
x.DepartmentId == employee.DepartmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.Company == employee.Company &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.Department == null)
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
if (route is null)
@@ -154,12 +151,12 @@ public sealed class TemplateRouteService(
}
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 =>
x.Id != excludeId &&
x.RequestTypeId == requestTypeId &&
x.Department == department &&
x.Company == company &&
x.DepartmentId == departmentId &&
x.CompanyId == companyId &&
x.ByProject == byProject,
cancellationToken);
@@ -181,8 +178,6 @@ public sealed class TemplateRouteService(
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(
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()
{
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()
{
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;
public class RequestType
{
public Guid Id { get; set; } = Guid.NewGuid();
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string PageUrl { get; set; } = string.Empty;
@@ -4,14 +4,14 @@ namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRoute
{
public Guid Id { get; set; } = Guid.NewGuid();
public int Id { get; set; }
public RouteType RouteType { get; set; } = RouteType.Sequential;
public Guid RequestTypeId { get; set; }
public int RequestTypeId { get; set; }
// Компания и отдел инициатора — названия из справочника AD (api-tnsu-portal /Employee/ByEmail),
// хранятся строками без FK в этой БД. Отдел опционален, компания обязательна.
public string? Department { get; set; }
public string Company { get; set; } = string.Empty;
// Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД.
// Отдел опционален, компания обязательна.
public Guid? DepartmentId { get; set; }
public Guid CompanyId { get; set; }
public bool ByProject { get; set; }
public bool RequiresControl { get; set; }
@@ -4,7 +4,7 @@ namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRouteStep
{
public Guid Id { get; set; } = Guid.NewGuid();
public int Id { get; set; }
public int OrderNo { get; set; }
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
@@ -14,7 +14,7 @@ public class TemplateRouteStep
public HeadType HeadType { get; set; } = HeadType.None;
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
public Guid RouteId { get; set; }
public int RouteId { get; set; }
public bool AllowReassignToOtherApprovers { get; set; }
public TemplateRoute? Route { get; set; }
@@ -4,5 +4,6 @@ public enum HeadType
{
None,
Chief,
DirectionHead
DirectionHead,
DirectorTansu
}
@@ -13,6 +13,8 @@ public static class InfrastructureServiceCollectionExtensions
public static IServiceCollection AddApprovalRouteInfrastructure(
this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpContextAccessor();
services.AddDbContext<ApprovalRouteDbContext>(opt =>
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
.UseSnakeCaseNamingConvention());
@@ -22,10 +22,8 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
{
e.HasKey(x => x.Id);
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.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 =>
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260724071302_InitialCreate")]
[Migration("20260724114550_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
@@ -27,11 +27,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -52,28 +54,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<string>("Company")
.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")
b.Property<Guid>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
@@ -89,20 +90,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "Department", "Company", "ByProject")
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.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);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
@@ -135,8 +138,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<Guid>("RouteId")
.HasColumnType("uuid")
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<string>("Status")
@@ -1,5 +1,6 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
@@ -15,7 +16,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "request_types",
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),
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",
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),
request_type_id = table.Column<Guid>(type: "uuid", nullable: false),
department = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
company = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
request_type_id = table.Column<int>(type: "integer", nullable: false),
department_id = table.Column<Guid>(type: "uuid", nullable: true),
company_id = table.Column<Guid>(type: "uuid", nullable: false),
by_project = table.Column<bool>(type: "boolean", nullable: false),
requires_control = table.Column<bool>(type: "boolean", nullable: false)
},
@@ -51,14 +54,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "template_route_steps",
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),
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
route_id = table.Column<Guid>(type: "uuid", nullable: false),
route_id = table.Column<int>(type: "integer", nullable: false),
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
@@ -78,9 +82,9 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
columns: new[] { "route_id", "order_no" });
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",
columns: new[] { "request_type_id", "department", "company", "by_project" },
columns: new[] { "request_type_id", "department_id", "company_id", "by_project" },
unique: true);
}
@@ -24,11 +24,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
@@ -49,28 +51,27 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<string>("Company")
.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")
b.Property<Guid>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
@@ -86,20 +87,22 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.HasKey("Id")
.HasName("pk_template_routes");
b.HasIndex("RequestTypeId", "Department", "Company", "ByProject")
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.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);
});
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{
b.Property<Guid>("Id")
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
@@ -132,8 +135,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<Guid>("RouteId")
.HasColumnType("uuid")
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<string>("Status")
@@ -13,5 +13,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -1,14 +1,23 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
// Клиент api.tnsu.kz (api-tnsu-portal). Сервисный токен прокидывается через TnsuPortalTokenProvider.
public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenProvider tokenProvider) : ITnsuPortalClient
// Клиент api.tnsu.kz (api-tnsu-portal).
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)
{
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;
response.EnsureSuccessStatusCode();
var employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>(cancellationToken);
var employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>(JsonOptions, cancellationToken);
if (employee is 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)
@@ -36,7 +45,7 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP
return [];
response.EnsureSuccessStatusCode();
var project = await response.Content.ReadFromJsonAsync<ProjectResponse>(cancellationToken);
var project = await response.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions, cancellationToken);
if (project is null)
return [];
@@ -56,15 +65,25 @@ public sealed class HttpTnsuPortalClient(HttpClient httpClient, TnsuPortalTokenP
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)
{
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(incoming))
{
request.Headers.TryAddWithoutValidation("Authorization", incoming);
return;
}
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
private sealed record EmployeeResponse(
[property: JsonPropertyName("company")] string? Company,
[property: JsonPropertyName("section")] string? Section);
[property: JsonPropertyName("companyId")] Guid? CompanyId,
[property: JsonPropertyName("departmentId")] Guid? DepartmentId);
private sealed record ProjectResponse(
[property: JsonPropertyName("projectManager")] string? ProjectManager,
@@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums;
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);
@@ -33,7 +33,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task GetAsync_UnknownId_ReturnsFailedResult()
{
// Action
var result = await _service.GetAsync(Guid.NewGuid(), CancellationToken.None);
var result = await _service.GetAsync(999999, CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
@@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{
// Action
var result = await _service.UpdateAsync(
Guid.NewGuid(), new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
@@ -12,8 +12,8 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private const string Company = "ТОО TANSU Construction";
private const string Department = "IT";
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly ServiceProvider _services;
private readonly TemplateRouteService _service;
@@ -24,7 +24,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(Company, Department));
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId));
portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
@@ -34,7 +34,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
_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;
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
@@ -45,7 +45,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Action
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);
// Assert
@@ -54,7 +54,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsSuccess.ShouldBeTrue();
fetched.Value.Company.ShouldBe(Company);
fetched.Value.CompanyId.ShouldBe(CompanyId);
}
[Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
@@ -62,7 +62,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{
// Action
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);
// Assert
@@ -74,7 +74,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{
// Arrange
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);
first.IsSuccess.ShouldBeTrue();
@@ -91,13 +91,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
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);
// Action
var updated = await _service.UpdateAsync(
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);
// Assert
@@ -112,7 +112,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work");
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);
// Action
@@ -131,7 +131,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request");
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);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -158,7 +158,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request");
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);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
@@ -184,7 +184,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
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);
// Action
@@ -202,7 +202,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
{
// Action
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);
// Assert
@@ -10,7 +10,7 @@ namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
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 TemplateRouteService _routeService;
@@ -24,13 +24,13 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
_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(
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id;
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);
return route.Value.Id;
@@ -41,7 +41,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{
// Action
var result = await _service.CreateAsync(
Guid.NewGuid(),
999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
@@ -99,7 +99,8 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Assert
created.IsSuccess.ShouldBeTrue();
created.Reasons.ShouldContain(r => r.Message.Contains("ролью в проекте"));
created.Value.Warning.ShouldNotBeNull();
created.Value.Warning.ShouldContain("ролью в проекте");
}
[Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")]
@@ -116,7 +117,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Assert
created.IsSuccess.ShouldBeTrue();
created.Reasons.ShouldBeEmpty();
created.Value.Warning.ShouldBeNull();
}
[Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")]