1ee1b71e3e
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14
328 lines
20 KiB
C#
328 lines
20 KiB
C#
using FluentResults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
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;
|
|
|
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
|
|
|
public sealed class TemplateRouteStepService(
|
|
ApprovalRouteDbContext db, ICurrentUser currentUser, ILogger<TemplateRouteStepService> logger)
|
|
{
|
|
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, null, x.SlaHours, x.AskOverdueReason))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для
|
|
/// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen):
|
|
/// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не
|
|
/// попадают — это не привязка к конкретному человеку.
|
|
/// </summary>
|
|
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListByExecutorEmailAsync(
|
|
string executorEmail, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(executorEmail))
|
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
|
|
|
|
var items = await db.TemplateRouteSteps.AsNoTracking()
|
|
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
|
|
.OrderBy(x => x.RouteId).ThenBy(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, null, x.SlaHours, x.AskOverdueReason))
|
|
.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,
|
|
SlaHours = r.SlaHours,
|
|
AskOverdueReason = r.AskOverdueReason
|
|
};
|
|
db.TemplateRouteSteps.Add(entity);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
logger.LogInformation(
|
|
"Шаг {StepId} маршрута {RouteId} создан пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
|
|
|
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;
|
|
entity.SlaHours = r.SlaHours;
|
|
entity.AskOverdueReason = r.AskOverdueReason;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
logger.LogInformation(
|
|
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
|
|
|
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;
|
|
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
|
|
// наследованию SLA от типа заявки можно через PUT.
|
|
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
|
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
logger.LogInformation(
|
|
"Шаг {StepId} маршрута {RouteId} изменён пользователем {Author}", entity.Id, route.Id, currentUser.Email);
|
|
|
|
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);
|
|
logger.LogInformation(
|
|
"Шаг {StepId} маршрута {RouteId} удалён пользователем {Author}", id, routeId, currentUser.Email);
|
|
|
|
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, null, e.SlaHours, e.AskOverdueReason);
|
|
}
|