от src избавился

This commit is contained in:
gorbenko.pavel
2026-08-12 12:19:04 +05:00
parent 509f26de7b
commit 4b83d666f1
71 changed files with 67 additions and 1135 deletions
@@ -0,0 +1,22 @@
using System.Reflection;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
namespace Tnsu.ApprovalRoute.Application;
public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
{
services.AddScoped<RequestTypeService>();
services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>();
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}
@@ -0,0 +1,10 @@
using FluentResults;
namespace Tnsu.ApprovalRoute.Application.Common.Errors;
public sealed class NotFoundError(string entity, object id)
: Error($"{entity} с id {id} не найден.");
public sealed class ValidationError(string message) : Error(message);
public sealed class ConflictError(string message) : Error(message);
@@ -0,0 +1,6 @@
namespace Tnsu.ApprovalRoute.Application.Common.Interfaces;
public interface ICurrentUser
{
string? Email { get; }
}
@@ -0,0 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListId, string? SiteUrl, string? Section);
@@ -0,0 +1,106 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed class RequestTypeService(ApprovalRouteDbContext db)
{
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
{
var items = await db.RequestTypes.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
}
public async Task<Result<RequestTypeDto>> GetAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return entity is null
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
{
if (!RequestTypeFields.IsListReferenceValid(request.ListId, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
var entity = new RequestType
{
Name = request.Name,
PageUrl = request.PageUrl,
ListId = request.ListId,
SiteUrl = request.SiteUrl,
Section = request.Section
};
db.RequestTypes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result<RequestTypeDto>> UpdateAsync(int id, UpdateRequestTypeRequest request, CancellationToken cancellationToken)
{
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
if (!RequestTypeFields.IsListReferenceValid(request.ListId, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name;
entity.PageUrl = request.PageUrl;
entity.ListId = request.ListId;
entity.SiteUrl = request.SiteUrl;
entity.Section = request.Section;
await db.SaveChangesAsync(cancellationToken);
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));
var listId = request.ListId ?? entity.ListId;
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
if (!RequestTypeFields.IsListReferenceValid(listId, siteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name ?? entity.Name;
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
entity.ListId = listId;
entity.SiteUrl = siteUrl;
entity.Section = request.Section ?? entity.Section;
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);
if (entity is null)
return Result.Fail(new NotFoundError("Тип заявки", id));
db.RequestTypes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section);
}
@@ -0,0 +1,48 @@
using FluentValidation;
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed class CreateRequestTypeRequestValidator : AbstractValidator<CreateRequestTypeRequest>
{
public CreateRequestTypeRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
RuleFor(x => x)
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
}
}
public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<UpdateRequestTypeRequest>
{
public UpdateRequestTypeRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен.");
RuleFor(x => x)
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
}
}
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 страницы не может быть пустым."));
When(x => x.Section is not null, () =>
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
}
}
internal static class RequestTypeFields
{
public static bool IsListReferenceValid(string? listId, string? siteUrl) =>
string.IsNullOrWhiteSpace(listId) == string.IsNullOrWhiteSpace(siteUrl);
}
@@ -0,0 +1,54 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed record TemplateRouteStepDto(
int Id,
int OrderNo,
string? ExecutorEmail,
string? Position,
ProjectRole? ProjectRole,
HeadType? HeadType,
RouteStepStatus Status,
int RouteId,
bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value,
string? Warning = null);
public sealed record CreateTemplateRouteStepRequest(
int OrderNo,
string? ExecutorEmail,
string? Position,
ProjectRole? ProjectRole,
HeadType? HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
public sealed record UpdateTemplateRouteStepRequest(
int OrderNo,
string? ExecutorEmail,
string? Position,
ProjectRole? ProjectRole,
HeadType? HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
public sealed record PatchTemplateRouteStepRequest(
int? OrderNo,
string? ExecutorEmail,
string? Position,
ProjectRole? ProjectRole,
HeadType? HeadType,
RouteStepStatus? Status,
bool? AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
@@ -0,0 +1,285 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
{
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{
var items = await db.TemplateRouteSteps.AsNoTracking()
.Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto(
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
x.FieldName, x.Operation, x.Value))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
}
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRouteSteps.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
return entity is null
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{
var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep
{
RouteId = route.Id,
OrderNo = r.OrderNo,
ExecutorEmail = r.ExecutorEmail,
Position = r.Position,
ProjectRole = r.ProjectRole,
HeadType = r.HeadType,
Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
FieldName = r.FieldName,
Operation = r.Operation,
Value = r.Value
};
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
{
var route = await db.TemplateRoutes.Include(x => x.RequestType).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));
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
entity.Position = r.Position;
entity.ProjectRole = r.ProjectRole;
entity.HeadType = r.HeadType;
entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
entity.FieldName = r.FieldName;
entity.Operation = r.Operation;
entity.Value = r.Value;
await db.SaveChangesAsync(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.Include(x => x.RequestType).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;
var fieldName = r.FieldName ?? entity.FieldName;
var operation = r.Operation ?? entity.Operation;
var value = r.Value ?? entity.Value;
if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
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(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status,
fieldName, operation, value, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
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;
entity.FieldName = fieldName;
entity.Operation = operation;
entity.Value = value;
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);
if (entity is null)
return Result.Fail(new NotFoundError("Шаг маршрута", id));
db.TemplateRouteSteps.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) =>
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
/// <summary>
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
/// условии появления (field_name/operation/value): шаги с разными условиями
/// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут,
/// а в один резолв — нет.
/// </summary>
private Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value,
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 &&
x.FieldName == fieldName &&
x.Operation == operation &&
x.Value == value,
cancellationToken);
/// <summary>
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
/// DepartmentHead, единственный departmentId запроса (см.
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
/// в одного и того же (или неотличимо какого) руководителя — например, «два
/// разных директора одной и той же компании» просто не существуют. В отличие
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных
/// полей шага. Исключение — условие появления шага: при разных условиях
/// в конкретный резолв попадёт только один из шагов, так что одного и того же
/// руководителя дважды в маршруте не окажется.
/// </summary>
private Task<bool> HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value,
int? excludeId, CancellationToken cancellationToken)
{
if (headType is null or HeadType.None)
return Task.FromResult(false);
return db.TemplateRouteSteps.AnyAsync(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
x.HeadType == headType &&
x.FieldName == fieldName &&
x.Operation == operation &&
x.Value == value,
cancellationToken);
}
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{
string? warning = null;
if (route.ByProject)
{
var hasProjectRoleStep = await db.TemplateRouteSteps
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
if (!hasProjectRoleStep)
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
}
return ToDto(entity) with { Warning = warning };
}
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
e.FieldName, e.Operation, e.Value);
}
@@ -0,0 +1,52 @@
using FluentValidation;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<CreateTemplateRouteStepRequest>
{
public CreateTemplateRouteStepRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
}
}
public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<UpdateTemplateRouteStepRequest>
{
public UpdateTemplateRouteStepRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
}
}
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);
public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) =>
(string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) +
(operation is null or ComparisonOperator.None ? 0 : 1) +
(string.IsNullOrWhiteSpace(value) ? 0 : 1);
public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value)
{
var count = CountConditionFilled(fieldName, operation, value);
return count == 0 || count == 3;
}
}
@@ -0,0 +1,34 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolvedRouteStepDto(
int Id,
int OrderNo,
string? ExecutorEmail,
string? Position,
bool AllowReassignToOtherApprovers,
string? Warning = null);
public sealed record ResolveTemplateRouteResultDto(
int RouteId,
bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps);
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
public sealed record ResolveFieldValueDto(string FieldName, string Value);
/// <param name="DepartmentId">
/// Отдел для шагов с HeadType.DepartmentHead ("руководитель указанного отдела") —
/// не выводится из инициатора, поэтому должен быть передан явно. Необязателен:
/// если в маршруте нет такого шага, поле не нужно; если есть, а поле не
/// передано — шаг не резолвится и получает предупреждение (см.
/// TemplateRouteService.ResolveStepAsync), весь запрос при этом не падает.
/// </param>
public sealed record ResolveTemplateRouteRequest(
int RequestTypeId,
string? UserEmail,
Guid? ProjectId,
RouteStepStatus Status,
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null,
Guid? DepartmentId = null);
@@ -0,0 +1,28 @@
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record TemplateRouteDto(
int Id,
int RequestTypeId,
Guid? DepartmentId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record CreateTemplateRouteRequest(
int RequestTypeId,
Guid? DepartmentId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record UpdateTemplateRouteRequest(
Guid? DepartmentId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record PatchTemplateRouteRequest(
Guid? DepartmentId,
Guid? CompanyId,
bool? ByProject,
bool? RequiresControl);
@@ -0,0 +1,332 @@
using System.Globalization;
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class TemplateRouteService(
ApprovalRouteDbContext db,
ITnsuPortalClient portalClient,
ICurrentUser currentUser,
IOptions<TnsuPortalOptions> portalOptions)
{
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
{
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
if (requestTypeId is { } id)
query = query.Where(x => x.RequestTypeId == id);
var items = await query
.Select(x => new TemplateRouteDto(
x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
}
public async Task<Result<TemplateRouteDto>> GetAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return entity is null
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteDto>> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken)
{
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
if (!requestTypeExists)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
var entity = new TemplateRoute
{
RequestTypeId = r.RequestTypeId,
DepartmentId = r.DepartmentId,
CompanyId = r.CompanyId,
ByProject = r.ByProject,
RequiresControl = r.RequiresControl
};
db.TemplateRoutes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteDto>> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken)
{
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.DepartmentId = r.DepartmentId;
entity.CompanyId = r.CompanyId;
entity.ByProject = r.ByProject;
entity.RequiresControl = r.RequiresControl;
await db.SaveChangesAsync(cancellationToken);
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.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);
if (entity is null)
return Result.Fail(new NotFoundError("Маршрут", id));
db.TemplateRoutes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
public async Task<Result<ResolveTemplateRouteResultDto>> ResolveAsync(
ResolveTemplateRouteRequest request, CancellationToken cancellationToken)
{
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
if (string.IsNullOrWhiteSpace(email))
return Result.Fail<ResolveTemplateRouteResultDto>(
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken);
if (!requestTypeExists)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employee is null)
return Result.Fail<ResolveTemplateRouteResultDto>(
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
var byProject = request.ProjectId is not null;
// Приоритет — маршрут с компанией инициатора; если для этой компании
// своего маршрута нет, берём запасной маршрут без указанной компании
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
request.RequestTypeId));
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
if (request.ProjectId is { } projectId)
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
var fieldValues = (request.FieldValues ?? [])
.Where(f => !string.IsNullOrWhiteSpace(f.FieldName))
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
var matchingSteps = route.Steps
.Where(s => s.Status == request.Status)
.Where(s => MatchesCondition(s, fieldValues))
.OrderBy(s => s.OrderNo)
.ToList();
var steps = new List<ResolvedRouteStepDto>();
foreach (var step in matchingSteps)
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, request.DepartmentId, cancellationToken));
var collapsedSteps = CollapseByExecutorEmail(steps);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
}
/// <summary>
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
/// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает
/// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null).
/// </summary>
private async Task<TemplateRoute?> FindRouteAsync(
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId &&
x.ByProject == byProject &&
x.DepartmentId == departmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
private Task<bool> HasDuplicateAsync(
int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId &&
x.RequestTypeId == requestTypeId &&
x.DepartmentId == departmentId &&
x.CompanyId == companyId &&
x.ByProject == byProject,
cancellationToken);
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, CancellationToken cancellationToken)
{
string? resolvedEmail;
string? warning = null;
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
{
resolvedEmail = step.ExecutorEmail;
}
else if (!string.IsNullOrWhiteSpace(step.Position))
{
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден сотрудник на должности «{step.Position}» в компании «{initiatorCompany}».";
}
else if (step.ProjectRole is not (null or ProjectRole.None))
{
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
}
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
// ResolveTemplateRouteRequest.DepartmentId): отдел инициатора тут ни при
// чём, шаг про «руководителя ТАКОГО-ТО отдела», а не «моего руководителя».
// Поэтому у него отдельная ветка, а не место в switch чуть ниже.
else if (step.HeadType == HeadType.DepartmentHead)
{
if (departmentId is null)
{
resolvedEmail = null;
warning = "Шаг требует руководителя указанного отдела, но departmentId не передан в запросе.";
}
else
{
resolvedEmail = await portalClient.GetDepartmentHeadEmailAsync(departmentId.Value, cancellationToken);
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель отдела с id «{departmentId}».";
}
}
else if (step.HeadType is not (null or HeadType.None))
{
resolvedEmail = step.HeadType switch
{
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
_ => null
};
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель типа «{step.HeadType}».";
}
else
{
resolvedEmail = null;
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
}
string? resolvedPosition = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
resolvedPosition = resolvedEmployee?.Position;
}
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
}
/// <summary>
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
/// сценарий, и отсутствие значения означает, что условие не выполняется.
/// </summary>
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
{
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None)
return true;
if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
return false;
var expected = step.Value;
return step.Operation switch
{
ComparisonOperator.Equal =>
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected),
ComparisonOperator.GreaterThan =>
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
ComparisonOperator.LessThan =>
TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected,
_ => true
};
}
private static bool TryParseNumber(string? value, out double number) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
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(
e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
}
@@ -0,0 +1,22 @@
using FluentValidation;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
// Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/
// UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных
// валидаторах: маршрут без компании служит запасным вариантом, когда для
// конкретной компании инициатора своего маршрута нет (см. ResolveAsync).
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{
public ResolveTemplateRouteRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleForEach(x => x.FieldValues).ChildRules(v =>
{
v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно.");
v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно.");
});
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentResults" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
</Project>