333 lines
17 KiB
C#
333 lines
17 KiB
C#
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);
|
||
}
|