@@ -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 ) ;
}