добавлены patch
This commit is contained in:
@@ -5,7 +5,6 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||
|
||||
// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID).
|
||||
public sealed class DevAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
|
||||
@@ -35,6 +35,13 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
|
||||
public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
|
||||
(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}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[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) =>
|
||||
(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}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[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) =>
|
||||
(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}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -5,7 +5,6 @@ using Tnsu.ApprovalRoute.Api.Common;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Filters;
|
||||
|
||||
// Валидирует аргументы action'ов, для которых зарегистрирован IValidator<T> — без валидации внутри сервисов.
|
||||
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
|
||||
@@ -47,7 +47,6 @@ 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();
|
||||
@@ -56,8 +55,6 @@ if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
|
||||
// Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security)
|
||||
// и корректно прикрепляет Bearer-токен к запросам.
|
||||
app.MapScalarApiReference(options =>
|
||||
{
|
||||
options
|
||||
|
||||
@@ -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 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));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 страницы обязателен.");
|
||||
}
|
||||
}
|
||||
|
||||
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 страницы не может быть пустым."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,3 +31,12 @@ public sealed record UpdateTemplateRouteStepRequest(
|
||||
HeadType HeadType,
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers);
|
||||
|
||||
public sealed record PatchTemplateRouteStepRequest(
|
||||
int? OrderNo,
|
||||
string? ExecutorEmail,
|
||||
string? Position,
|
||||
ProjectRole? ProjectRole,
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus? Status,
|
||||
bool? AllowReassignToOtherApprovers);
|
||||
|
||||
+44
-1
@@ -103,6 +103,50 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
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 != ProjectRole.None)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) &&
|
||||
projectRole == ProjectRole.None && headType == HeadType.None)
|
||||
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)
|
||||
{
|
||||
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
@@ -128,7 +172,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
x.Status == status,
|
||||
cancellationToken);
|
||||
|
||||
// Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning.
|
||||
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -25,3 +25,10 @@ public sealed record UpdateTemplateRouteRequest(
|
||||
Guid CompanyId,
|
||||
bool ByProject,
|
||||
bool RequiresControl);
|
||||
|
||||
public sealed record PatchTemplateRouteRequest(
|
||||
RouteType? RouteType,
|
||||
Guid? DepartmentId,
|
||||
Guid? CompanyId,
|
||||
bool? ByProject,
|
||||
bool? RequiresControl);
|
||||
|
||||
@@ -84,6 +84,31 @@ public sealed class TemplateRouteService(
|
||||
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)
|
||||
{
|
||||
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
@@ -162,8 +187,6 @@ public sealed class TemplateRouteService(
|
||||
|
||||
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors)
|
||||
{
|
||||
// Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz);
|
||||
// должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона.
|
||||
if (step.ProjectRole != ProjectRole.None)
|
||||
{
|
||||
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole);
|
||||
|
||||
@@ -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 ResolveTemplateRouteRequestValidator()
|
||||
|
||||
@@ -8,8 +8,6 @@ public class TemplateRoute
|
||||
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
||||
public int RequestTypeId { get; set; }
|
||||
|
||||
// Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД.
|
||||
// Отдел опционален, компания обязательна.
|
||||
public Guid? DepartmentId { get; set; }
|
||||
public Guid CompanyId { get; set; }
|
||||
public bool ByProject { get; set; }
|
||||
|
||||
@@ -7,7 +7,6 @@ public class TemplateRouteStep
|
||||
public int Id { get; set; }
|
||||
public int OrderNo { get; set; }
|
||||
|
||||
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
|
||||
public string ExecutorEmail { get; set; } = string.Empty;
|
||||
public string Position { get; set; } = string.Empty;
|
||||
public ProjectRole ProjectRole { get; set; } = ProjectRole.None;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal
|
||||
// (project_manager / planner / manager_hse / security).
|
||||
public enum ProjectRole
|
||||
{
|
||||
None,
|
||||
|
||||
@@ -8,14 +8,11 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
// Клиент 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)
|
||||
@@ -49,7 +46,6 @@ public sealed class HttpTnsuPortalClient(
|
||||
if (project is null)
|
||||
return [];
|
||||
|
||||
// Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security).
|
||||
var executors = new List<ProjectExecutorInfo>();
|
||||
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
|
||||
AddIfPresent(executors, ProjectRole.Planner, project.Planner);
|
||||
@@ -65,9 +61,6 @@ public sealed class HttpTnsuPortalClient(
|
||||
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();
|
||||
|
||||
@@ -4,7 +4,6 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя.
|
||||
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
|
||||
{
|
||||
private string? _cachedToken;
|
||||
|
||||
Reference in New Issue
Block a user