Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8479190ec7 | |||
| aa49db309b | |||
| 13f557e1f4 | |||
| 8f92a9646d | |||
| 5928377465 |
@@ -5,7 +5,6 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID).
|
|
||||||
public sealed class DevAuthHandler(
|
public sealed class DevAuthHandler(
|
||||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
|
|||||||
public async Task<IActionResult> Update(int 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();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ public sealed class TemplateRouteStepsController(TemplateRouteStepService servic
|
|||||||
public async Task<IActionResult> Update(int routeId, int 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();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(routeId, id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con
|
|||||||
public async Task<IActionResult> Update(int 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();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using Tnsu.ApprovalRoute.Api.Common;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Api.Filters;
|
namespace Tnsu.ApprovalRoute.Api.Filters;
|
||||||
|
|
||||||
// Валидирует аргументы action'ов, для которых зарегистрирован IValidator<T> — без валидации внутри сервисов.
|
|
||||||
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
|
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
|
||||||
{
|
{
|
||||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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()));
|
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -56,8 +55,6 @@ if (app.Environment.IsDevelopment())
|
|||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
|
|
||||||
// Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security)
|
|
||||||
// и корректно прикрепляет Bearer-токен к запросам.
|
|
||||||
app.MapScalarApiReference(options =>
|
app.MapScalarApiReference(options =>
|
||||||
{
|
{
|
||||||
options
|
options
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"TenantId": "",
|
"TenantId": "",
|
||||||
"ClientId": "",
|
"ClientId": "",
|
||||||
"ClientSecret": "",
|
"ClientSecret": "",
|
||||||
"Scope": ""
|
"Scope": "",
|
||||||
|
"DirectorTansuEmail": "director.tansu@tnsu.kz"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ 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);
|
||||||
|
|
||||||
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl);
|
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl);
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
|||||||
return Result.Ok(ToDto(entity));
|
return Result.Ok(ToDto(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> PatchAsync(int id, PatchRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
entity.Name = request.Name ?? entity.Name;
|
||||||
|
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
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);
|
||||||
|
|||||||
@@ -19,3 +19,14 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<Update
|
|||||||
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public PatchRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Name is not null, () =>
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
|
||||||
|
When(x => x.PageUrl is not null, () =>
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-12
@@ -5,10 +5,10 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
|||||||
public sealed record TemplateRouteStepDto(
|
public sealed record TemplateRouteStepDto(
|
||||||
int 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,
|
||||||
int RouteId,
|
int RouteId,
|
||||||
bool AllowReassignToOtherApprovers,
|
bool AllowReassignToOtherApprovers,
|
||||||
@@ -16,18 +16,27 @@ public sealed record TemplateRouteStepDto(
|
|||||||
|
|
||||||
public sealed record CreateTemplateRouteStepRequest(
|
public sealed record CreateTemplateRouteStepRequest(
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
string ExecutorEmail,
|
string? ExecutorEmail,
|
||||||
string Position,
|
string? Position,
|
||||||
ProjectRole ProjectRole,
|
ProjectRole? ProjectRole,
|
||||||
HeadType HeadType,
|
HeadType? HeadType,
|
||||||
RouteStepStatus Status,
|
RouteStepStatus Status,
|
||||||
bool AllowReassignToOtherApprovers);
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
public sealed record UpdateTemplateRouteStepRequest(
|
public sealed record UpdateTemplateRouteStepRequest(
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
string ExecutorEmail,
|
string? ExecutorEmail,
|
||||||
string Position,
|
string? Position,
|
||||||
ProjectRole ProjectRole,
|
ProjectRole? ProjectRole,
|
||||||
HeadType HeadType,
|
HeadType? HeadType,
|
||||||
RouteStepStatus Status,
|
RouteStepStatus Status,
|
||||||
bool AllowReassignToOtherApprovers);
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record PatchTemplateRouteStepRequest(
|
||||||
|
int? OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus? Status,
|
||||||
|
bool? AllowReassignToOtherApprovers);
|
||||||
|
|||||||
+71
-4
@@ -37,10 +37,16 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
if (route is null)
|
if (route is null)
|
||||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
if (!route.ByProject && r.ProjectRole != ProjectRole.None)
|
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
var entity = new TemplateRouteStep
|
var entity = new TemplateRouteStep
|
||||||
{
|
{
|
||||||
RouteId = route.Id,
|
RouteId = route.Id,
|
||||||
@@ -69,10 +75,16 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
if (entity is null)
|
if (entity is null)
|
||||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
if (!route.ByProject && r.ProjectRole != ProjectRole.None)
|
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
|
||||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo;
|
entity.OrderNo = r.OrderNo;
|
||||||
entity.ExecutorEmail = r.ExecutorEmail;
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
entity.Position = r.Position;
|
entity.Position = r.Position;
|
||||||
@@ -85,6 +97,49 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> PatchAsync(
|
||||||
|
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail;
|
||||||
|
var position = r.Position ?? entity.Position;
|
||||||
|
var projectRole = r.ProjectRole ?? entity.ProjectRole;
|
||||||
|
var headType = r.HeadType ?? entity.HeadType;
|
||||||
|
var status = r.Status ?? entity.Status;
|
||||||
|
|
||||||
|
if (!route.ByProject && projectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
|
entity.ExecutorEmail = executorEmail;
|
||||||
|
entity.Position = position;
|
||||||
|
entity.ProjectRole = projectRole;
|
||||||
|
entity.HeadType = headType;
|
||||||
|
entity.Status = status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result> DeleteAsync(int routeId, int id, CancellationToken cancellationToken)
|
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);
|
||||||
@@ -97,7 +152,19 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Ok();
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning.
|
private Task<bool> HasDuplicateAsync(
|
||||||
|
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
|
||||||
|
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRouteSteps.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RouteId == routeId &&
|
||||||
|
x.ExecutorEmail == executorEmail &&
|
||||||
|
x.Position == position &&
|
||||||
|
x.ProjectRole == projectRole &&
|
||||||
|
x.HeadType == headType &&
|
||||||
|
x.Status == status,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||||
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -105,7 +172,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
if (route.ByProject)
|
if (route.ByProject)
|
||||||
{
|
{
|
||||||
var hasProjectRoleStep = await db.TemplateRouteSteps
|
var hasProjectRoleStep = await db.TemplateRouteSteps
|
||||||
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
|
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
|
||||||
if (!hasProjectRoleStep)
|
if (!hasProjectRoleStep)
|
||||||
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
|
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -8,8 +8,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
|
|||||||
public CreateTemplateRouteStepRequestValidator()
|
public CreateTemplateRouteStepRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None))
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +18,16 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
|
|||||||
public UpdateTemplateRouteStepRequestValidator()
|
public UpdateTemplateRouteStepRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x)
|
RuleFor(x => x)
|
||||||
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None))
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class TemplateRouteStepFields
|
||||||
|
{
|
||||||
|
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
|
||||||
|
(string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) +
|
||||||
|
(string.IsNullOrWhiteSpace(position) ? 0 : 1) +
|
||||||
|
(projectRole is null or ProjectRole.None ? 0 : 1) +
|
||||||
|
(headType is null or HeadType.None ? 0 : 1);
|
||||||
|
}
|
||||||
|
|||||||
+2
-5
@@ -5,11 +5,8 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
|||||||
public sealed record ResolvedRouteStepDto(
|
public sealed record ResolvedRouteStepDto(
|
||||||
int Id,
|
int Id,
|
||||||
int OrderNo,
|
int OrderNo,
|
||||||
string ExecutorEmail,
|
string? ExecutorEmail,
|
||||||
string Position,
|
string? Position,
|
||||||
ProjectRole ProjectRole,
|
|
||||||
HeadType HeadType,
|
|
||||||
RouteStepStatus Status,
|
|
||||||
bool AllowReassignToOtherApprovers);
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
public sealed record ResolveTemplateRouteResultDto(
|
public sealed record ResolveTemplateRouteResultDto(
|
||||||
|
|||||||
@@ -25,3 +25,10 @@ public sealed record UpdateTemplateRouteRequest(
|
|||||||
Guid CompanyId,
|
Guid CompanyId,
|
||||||
bool ByProject,
|
bool ByProject,
|
||||||
bool RequiresControl);
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record PatchTemplateRouteRequest(
|
||||||
|
RouteType? RouteType,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? CompanyId,
|
||||||
|
bool? ByProject,
|
||||||
|
bool? RequiresControl);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
@@ -12,7 +13,8 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
|||||||
public sealed class TemplateRouteService(
|
public sealed class TemplateRouteService(
|
||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
ITnsuPortalClient portalClient,
|
ITnsuPortalClient portalClient,
|
||||||
ICurrentUser currentUser)
|
ICurrentUser currentUser,
|
||||||
|
IOptions<TnsuPortalOptions> portalOptions)
|
||||||
{
|
{
|
||||||
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
|
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -84,6 +86,31 @@ public sealed class TemplateRouteService(
|
|||||||
return Result.Ok(ToDto(entity));
|
return Result.Ok(ToDto(entity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
var departmentId = r.DepartmentId ?? entity.DepartmentId;
|
||||||
|
var companyId = r.CompanyId ?? entity.CompanyId;
|
||||||
|
var byProject = r.ByProject ?? entity.ByProject;
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.RouteType = r.RouteType ?? entity.RouteType;
|
||||||
|
entity.DepartmentId = departmentId;
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.ByProject = byProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
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);
|
||||||
@@ -141,13 +168,18 @@ public sealed class TemplateRouteService(
|
|||||||
if (request.ProjectId is { } projectId)
|
if (request.ProjectId is { } projectId)
|
||||||
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||||||
|
|
||||||
var steps = route.Steps
|
var matchingSteps = route.Steps
|
||||||
.Where(s => s.Status == request.Status)
|
.Where(s => s.Status == request.Status)
|
||||||
.OrderBy(s => s.OrderNo)
|
.OrderBy(s => s.OrderNo)
|
||||||
.Select(s => ResolveStep(s, projectExecutors))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps));
|
var steps = new List<ResolvedRouteStepDto>();
|
||||||
|
foreach (var step in matchingSteps)
|
||||||
|
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken));
|
||||||
|
|
||||||
|
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||||
|
|
||||||
|
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<bool> HasDuplicateAsync(
|
private Task<bool> HasDuplicateAsync(
|
||||||
@@ -160,22 +192,53 @@ public sealed class TemplateRouteService(
|
|||||||
x.ByProject == byProject,
|
x.ByProject == byProject,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors)
|
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||||
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||||
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz);
|
string? resolvedEmail;
|
||||||
// должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона.
|
|
||||||
if (step.ProjectRole != ProjectRole.None)
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||||
|
resolvedEmail = step.ExecutorEmail;
|
||||||
|
else if (!string.IsNullOrWhiteSpace(step.Position))
|
||||||
|
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||||||
|
else if (step.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
|
else if (step.HeadType is not (null or HeadType.None))
|
||||||
|
resolvedEmail = step.HeadType switch
|
||||||
{
|
{
|
||||||
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole);
|
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||||
if (projectExecutor is not null)
|
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||||
return new ResolvedRouteStepDto(
|
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||||
step.Id, step.OrderNo, projectExecutor.Email, step.Position,
|
_ => null
|
||||||
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
|
};
|
||||||
|
else
|
||||||
|
resolvedEmail = null;
|
||||||
|
|
||||||
|
string? resolvedPosition = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
{
|
||||||
|
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||||
|
resolvedPosition = resolvedEmployee?.Position;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ResolvedRouteStepDto(
|
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers);
|
||||||
step.Id, step.OrderNo, step.ExecutorEmail, step.Position,
|
}
|
||||||
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
|
|
||||||
|
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||||
|
{
|
||||||
|
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<ResolvedRouteStepDto>();
|
||||||
|
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result.Add(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<Upda
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class PatchTemplateRouteRequestValidator : AbstractValidator<PatchTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public PatchTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.CompanyId is not null, () =>
|
||||||
|
RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
|
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
|
||||||
{
|
{
|
||||||
public ResolveTemplateRouteRequestValidator()
|
public ResolveTemplateRouteRequestValidator()
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ public class TemplateRoute
|
|||||||
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
||||||
public int RequestTypeId { get; set; }
|
public int RequestTypeId { get; set; }
|
||||||
|
|
||||||
// Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД.
|
|
||||||
// Отдел опционален, компания обязательна.
|
|
||||||
public Guid? DepartmentId { get; set; }
|
public Guid? DepartmentId { get; set; }
|
||||||
public Guid CompanyId { get; set; }
|
public Guid CompanyId { get; set; }
|
||||||
public bool ByProject { get; set; }
|
public bool ByProject { get; set; }
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ public class TemplateRouteStep
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int OrderNo { get; set; }
|
public int OrderNo { get; set; }
|
||||||
|
|
||||||
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
|
public string? ExecutorEmail { get; set; }
|
||||||
public string ExecutorEmail { get; set; } = string.Empty;
|
public string? Position { get; set; }
|
||||||
public string Position { get; set; } = string.Empty;
|
public ProjectRole? ProjectRole { get; set; }
|
||||||
public ProjectRole ProjectRole { get; set; } = ProjectRole.None;
|
public HeadType? HeadType { get; set; }
|
||||||
public HeadType HeadType { get; set; } = HeadType.None;
|
|
||||||
|
|
||||||
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
|
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
|
||||||
public int RouteId { get; set; }
|
public int RouteId { get; set; }
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal
|
|
||||||
// (project_manager / planner / manager_hse / security).
|
|
||||||
public enum ProjectRole
|
public enum ProjectRole
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
modelBuilder.Entity<TemplateRouteStep>(e =>
|
modelBuilder.Entity<TemplateRouteStep>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired();
|
e.Property(x => x.ExecutorEmail).HasMaxLength(256);
|
||||||
e.Property(x => x.Position).HasMaxLength(128).IsRequired();
|
e.Property(x => x.Position).HasMaxLength(128);
|
||||||
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
|
||||||
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
|
||||||
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
||||||
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
||||||
|
|||||||
+1
-5
@@ -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("20260724114550_InitialCreate")]
|
[Migration("20260727103117_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -111,13 +111,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasColumnName("executor_email");
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
b.Property<string>("HeadType")
|
b.Property<string>("HeadType")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("head_type");
|
.HasColumnName("head_type");
|
||||||
@@ -127,13 +125,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("order_no");
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
b.Property<string>("Position")
|
b.Property<string>("Position")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasColumnName("position");
|
.HasColumnName("position");
|
||||||
|
|
||||||
b.Property<string>("ProjectRole")
|
b.Property<string>("ProjectRole")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("project_role");
|
.HasColumnName("project_role");
|
||||||
+4
-4
@@ -57,10 +57,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
id = table.Column<int>(type: "integer", nullable: false)
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
order_no = table.Column<int>(type: "integer", nullable: false),
|
order_no = table.Column<int>(type: "integer", nullable: false),
|
||||||
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
route_id = table.Column<int>(type: "integer", 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)
|
||||||
-4
@@ -108,13 +108,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasColumnName("executor_email");
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
b.Property<string>("HeadType")
|
b.Property<string>("HeadType")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("head_type");
|
.HasColumnName("head_type");
|
||||||
@@ -124,13 +122,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("order_no");
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
b.Property<string>("Position")
|
b.Property<string>("Position")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasColumnName("position");
|
.HasColumnName("position");
|
||||||
|
|
||||||
b.Property<string>("ProjectRole")
|
b.Property<string>("ProjectRole")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("project_role");
|
.HasColumnName("project_role");
|
||||||
|
|||||||
@@ -8,48 +8,45 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
// Клиент api.tnsu.kz (api-tnsu-portal).
|
|
||||||
public sealed class HttpTnsuPortalClient(
|
public sealed class HttpTnsuPortalClient(
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
TnsuPortalTokenProvider tokenProvider,
|
TnsuPortalTokenProvider tokenProvider,
|
||||||
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
|
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
|
||||||
{
|
{
|
||||||
// Разные эндпоинты api-tnsu-portal отдают JSON в разном регистре (Dapper-результаты — PascalCase,
|
|
||||||
// строго типизированные модели — camelCase), поэтому сопоставляем поля без учёта регистра.
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
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)}");
|
var employee = await GetAsync<EmployeeResponse>(
|
||||||
await AuthorizeAsync(request, cancellationToken);
|
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
|
||||||
|
|
||||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
return null;
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
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.CompanyId ?? Guid.Empty, employee.DepartmentId);
|
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employee = await GetAsync<EmployeeResponse>(
|
||||||
|
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
|
||||||
|
|
||||||
|
return employee?.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var manager = await GetAsync<ManagerResponse>(
|
||||||
|
$"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken);
|
||||||
|
|
||||||
|
return manager?.ManagerEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Project/{projectId}");
|
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
||||||
await AuthorizeAsync(request, cancellationToken);
|
|
||||||
|
|
||||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
return [];
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var project = await response.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions, cancellationToken);
|
|
||||||
if (project is null)
|
if (project is null)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
// Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security).
|
|
||||||
var executors = new List<ProjectExecutorInfo>();
|
var executors = new List<ProjectExecutorInfo>();
|
||||||
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
|
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
|
||||||
AddIfPresent(executors, ProjectRole.Planner, project.Planner);
|
AddIfPresent(executors, ProjectRole.Planner, project.Planner);
|
||||||
@@ -59,15 +56,25 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
return executors;
|
return executors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
await AuthorizeAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email)
|
private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(email))
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
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();
|
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
|
||||||
@@ -82,8 +89,14 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sealed record EmployeeResponse(
|
private sealed record EmployeeResponse(
|
||||||
|
[property: JsonPropertyName("email")] string? Email,
|
||||||
|
[property: JsonPropertyName("company")] string? Company,
|
||||||
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
||||||
[property: JsonPropertyName("departmentId")] Guid? DepartmentId);
|
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
||||||
|
[property: JsonPropertyName("position")] string? Position);
|
||||||
|
|
||||||
|
private sealed record ManagerResponse(
|
||||||
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|
||||||
private sealed record ProjectResponse(
|
private sealed record ProjectResponse(
|
||||||
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ public interface ITnsuPortalClient
|
|||||||
{
|
{
|
||||||
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId);
|
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position);
|
||||||
|
|
||||||
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ public sealed class TnsuPortalOptions
|
|||||||
public string ClientId { get; set; } = string.Empty;
|
public string ClientId { get; set; } = string.Empty;
|
||||||
public string ClientSecret { get; set; } = string.Empty;
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
public string Scope { get; set; } = string.Empty;
|
public string Scope { get; set; } = string.Empty;
|
||||||
|
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя.
|
|
||||||
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
|
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
|
||||||
{
|
{
|
||||||
private string? _cachedToken;
|
private string? _cachedToken;
|
||||||
|
|||||||
+27
@@ -66,6 +66,33 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
|
|||||||
result.IsFailed.ShouldBeTrue();
|
result.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч типа заявки меняет только переданное поле, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.Name.ShouldBe("Patched name");
|
||||||
|
patched.Value.PageUrl.ShouldBe("/original");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task PatchAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.PatchAsync(
|
||||||
|
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Удаление типа заявки убирает его из базы")]
|
[Fact(DisplayName = "Удаление типа заявки убирает его из базы")]
|
||||||
public async Task DeleteAsync_RemovesEntity()
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
{
|
{
|
||||||
|
|||||||
+193
-4
@@ -15,21 +15,24 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
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 static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private readonly string _connectionString;
|
||||||
private readonly ServiceProvider _services;
|
private readonly ServiceProvider _services;
|
||||||
private readonly TemplateRouteService _service;
|
private readonly TemplateRouteService _service;
|
||||||
private readonly RequestTypeService _requestTypeService;
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
|
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
|
||||||
{
|
{
|
||||||
|
_connectionString = fixture.ConnectionString;
|
||||||
|
|
||||||
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(CompanyId, DepartmentId));
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
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")]);
|
||||||
|
|
||||||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, portalClient: portalClient.Object);
|
_services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
_service = _services.GetRequiredService<TemplateRouteService>();
|
_service = _services.GetRequiredService<TemplateRouteService>();
|
||||||
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,29 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
updated.Value.RequiresControl.ShouldBeTrue();
|
updated.Value.RequiresControl.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч маршрута меняет только переданные поля, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Patch route request");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.RouteType.ShouldBe(RouteType.Parallel);
|
||||||
|
patched.Value.RequiresControl.ShouldBeTrue();
|
||||||
|
patched.Value.CompanyId.ShouldBe(CompanyId);
|
||||||
|
patched.Value.DepartmentId.ShouldBe(DepartmentId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Удаление маршрута убирает его из базы")]
|
[Fact(DisplayName = "Удаление маршрута убирает его из базы")]
|
||||||
public async Task DeleteAsync_RemovesEntity()
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
{
|
{
|
||||||
@@ -137,7 +163,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -152,6 +178,56 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")]
|
||||||
|
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("dup@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.Count.ShouldBe(2);
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].OrderNo.ShouldBe(1);
|
||||||
|
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
|
||||||
|
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
||||||
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
||||||
{
|
{
|
||||||
@@ -164,7 +240,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
await stepService.CreateAsync(
|
await stepService.CreateAsync(
|
||||||
route.Value.Id,
|
route.Value.Id,
|
||||||
new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -178,6 +254,119 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz");
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с должностью подставляет email сотрудника, найденного по должности и компании инициатора")]
|
||||||
|
public async Task ResolveAsync_ByPosition_UsesEmployeeFoundByPositionAndCompany()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("lead@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("lead@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("chief@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position"));
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("director.tansu@tnsu.kz");
|
||||||
|
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
|
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
|
||||||
{
|
{
|
||||||
|
|||||||
+112
-8
@@ -42,7 +42,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
999999,
|
999999,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -58,13 +58,50 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var result = await _service.CreateAsync(
|
var result = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.IsFailed.ShouldBeTrue();
|
result.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Повторное создание шага с теми же исполнителем, должностью, ролью, типом руководителя и статусом возвращает конфликт")]
|
||||||
|
public async Task CreateAsync_DuplicateStep_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
|
||||||
|
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false);
|
||||||
|
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var duplicate = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
|
||||||
|
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Head type only route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.HeadType.ShouldBe(HeadType.Chief);
|
||||||
|
created.Value.ExecutorEmail.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
|
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
|
||||||
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
|
||||||
{
|
{
|
||||||
@@ -74,7 +111,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -94,7 +131,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -112,7 +149,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
// Action
|
// Action
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -127,14 +164,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
var updated = await _service.UpdateAsync(
|
var updated = await _service.UpdateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
created.Value.Id,
|
created.Value.Id,
|
||||||
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Execution, true),
|
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -144,6 +181,73 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
|
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч шага маршрута меняет только переданные поля, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
patched.Value.OrderNo.ShouldBe(2);
|
||||||
|
patched.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч, после которого заполнено больше одного из полей исполнителя, возвращает неуспешный результат")]
|
||||||
|
public async Task PatchAsync_ResultsInMoreThanOneFilledField_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
|
||||||
|
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
|
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
|
||||||
public async Task DeleteAsync_RemovesStep()
|
public async Task DeleteAsync_RemovesStep()
|
||||||
{
|
{
|
||||||
@@ -151,7 +255,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
||||||
var created = await _service.CreateAsync(
|
var created = await _service.CreateAsync(
|
||||||
routeId,
|
routeId,
|
||||||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
|
|||||||
-2
@@ -5,8 +5,6 @@ using Xunit;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
// Отдельная БД на один xUnit-collection (= один тестовый класс) в общем инстансе Postgres.
|
|
||||||
// Тесты внутри класса делят эту БД; разные классы (разные коллекции) получают разные БД.
|
|
||||||
public sealed class PostgresDatabaseFixture : IAsyncLifetime
|
public sealed class PostgresDatabaseFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}";
|
private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}";
|
||||||
|
|||||||
-2
@@ -7,8 +7,6 @@ using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
|||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
// Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs),
|
|
||||||
// но с тестовой БД, тестовым ICurrentUser и подменяемым (Moq) клиентом api.tnsu.kz.
|
|
||||||
internal static class TestServiceProviderFactory
|
internal static class TestServiceProviderFactory
|
||||||
{
|
{
|
||||||
public static ServiceProvider Build(
|
public static ServiceProvider Build(
|
||||||
|
|||||||
Reference in New Issue
Block a user