6433299c62
Бэкенд сам читает маршруты, шаги и справочники портала (сотрудники с должностями, компании, отделы), фронт шлёт только id типов заявок и скачивает файл. Колонки: №, Роль, Исполнитель, Организация, Условие, Комментарий. Согласующие и исполнители — отдельными таблицами. Условие по CompaniesJson показывается как «организация». Директор Tansu и Управляющий директор Tansu выводятся с ФИО. Комментарий «назначает согласующего/исполнителя» — при галочке переназначения. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
250 lines
12 KiB
C#
250 lines
12 KiB
C#
using FluentResults;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Options;
|
||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||
using Tnsu.ApprovalRoute.Domain.Enums;
|
||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||
|
||
namespace Tnsu.ApprovalRoute.Application.Reports;
|
||
|
||
/// <summary>
|
||
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
|
||
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
|
||
/// организация, условие, комментарий) и отдаёт на вёрстку в
|
||
/// <see cref="RouteExportWorkbook"/>.
|
||
///
|
||
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
|
||
/// <see cref="RouteStepStatus"/>), у каждой своя нумерация.
|
||
/// </summary>
|
||
public sealed class RouteExportService(
|
||
ApprovalRouteDbContext db,
|
||
ITnsuPortalClient portalClient,
|
||
IOptions<TnsuPortalOptions> portalOptions)
|
||
{
|
||
private const string Empty = "—";
|
||
private const string Always = "Всегда";
|
||
private const string AnyOrganization = "Любая";
|
||
private const string AnyCompany = "любая компания";
|
||
private const string AnyDepartment = "любой отдел";
|
||
private const string ByRequest = "Определяется по заявке";
|
||
|
||
/// <summary>
|
||
/// Технические имена колонок списка, которые в таблице показываем по-человечески
|
||
/// (CompaniesJson — организации заявки).
|
||
/// </summary>
|
||
private static readonly Dictionary<string, string> ConditionFieldAliases = new(StringComparer.Ordinal)
|
||
{
|
||
["CompaniesJson"] = "организация"
|
||
};
|
||
|
||
private static readonly Dictionary<ProjectRole, string> ProjectRoleLabels = new()
|
||
{
|
||
[ProjectRole.ProjectManager] = "Руководитель проекта",
|
||
[ProjectRole.Planner] = "Планировщик",
|
||
[ProjectRole.ManagerHSE] = "Менеджер HSE",
|
||
[ProjectRole.Security] = "Безопасность",
|
||
[ProjectRole.Keeper] = "Заведующий склада"
|
||
};
|
||
|
||
private static readonly Dictionary<HeadType, string> HeadTypeLabels = new()
|
||
{
|
||
[HeadType.Chief] = "Непосредственный руководитель",
|
||
[HeadType.DirectionHead] = "Руководитель направления",
|
||
[HeadType.DirectorTansu] = "Директор Tansu",
|
||
[HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu",
|
||
[HeadType.DepartmentHead] = "Руководитель указанного отдела",
|
||
[HeadType.CompanyDirectors] = "Директора компаний инициатора"
|
||
};
|
||
|
||
private static readonly Dictionary<ComparisonOperator, string> OperatorSymbols = new()
|
||
{
|
||
[ComparisonOperator.GreaterThan] = ">",
|
||
[ComparisonOperator.LessThan] = "<",
|
||
[ComparisonOperator.Equal] = "=",
|
||
[ComparisonOperator.NotEqual] = "≠",
|
||
[ComparisonOperator.Contains] = "содержит",
|
||
[ComparisonOperator.NotContains] = "не содержит"
|
||
};
|
||
|
||
public async Task<Result<byte[]>> BuildAsync(IReadOnlyList<int>? requestTypeIds, CancellationToken cancellationToken)
|
||
{
|
||
if (requestTypeIds is not { Count: > 0 })
|
||
return Result.Fail("Не выбрано ни одного типа заявки.");
|
||
|
||
var types = await db.RequestTypes.AsNoTracking()
|
||
.Where(x => requestTypeIds.Contains(x.Id))
|
||
.OrderBy(x => x.Name)
|
||
.ToListAsync(cancellationToken);
|
||
if (types.Count == 0)
|
||
return Result.Fail("Выбранные типы заявок не найдены.");
|
||
|
||
var routes = await db.TemplateRoutes.AsNoTracking()
|
||
.Where(x => requestTypeIds.Contains(x.RequestTypeId))
|
||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||
.OrderBy(x => x.Id)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
var directory = await LoadDirectoryAsync(cancellationToken);
|
||
|
||
var sheets = types
|
||
.Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory))
|
||
.ToList();
|
||
|
||
return Result.Ok(RouteExportWorkbook.Build(sheets));
|
||
}
|
||
|
||
private async Task<PortalDirectory> LoadDirectoryAsync(CancellationToken cancellationToken)
|
||
{
|
||
// Справочники кешируются клиентом портала (см. CachingTnsuPortalClient).
|
||
var employees = await portalClient.GetAllEmployeesAsync(cancellationToken);
|
||
var companies = await portalClient.GetCompaniesAsync(cancellationToken);
|
||
var departments = await portalClient.GetDepartmentsAsync(cancellationToken);
|
||
|
||
return new PortalDirectory(
|
||
employees,
|
||
companies.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First().Name),
|
||
departments.GroupBy(d => d.Id).ToDictionary(g => g.Key, g => g.First().Name));
|
||
}
|
||
|
||
private RouteExportSheet BuildSheet(RequestType type, IReadOnlyList<TemplateRoute> routes, PortalDirectory directory)
|
||
{
|
||
var sections = routes
|
||
.Select(route =>
|
||
{
|
||
var organization = route.CompanyId is { } companyId
|
||
? directory.CompanyName(companyId)
|
||
: AnyOrganization;
|
||
return new RouteExportSection(
|
||
routes.Count < 2 ? null : RouteTitle(route, directory),
|
||
BuildGroups(route.Steps, organization, directory));
|
||
})
|
||
.ToList();
|
||
|
||
return new RouteExportSheet(type.Name, sections);
|
||
}
|
||
|
||
private static string RouteTitle(TemplateRoute route, PortalDirectory directory)
|
||
{
|
||
var company = route.CompanyId is { } c ? directory.CompanyName(c) : AnyCompany;
|
||
var department = route.DepartmentId is { } d ? directory.DepartmentName(d) : AnyDepartment;
|
||
return $"Маршрут: {company} · {department}{(route.ByProject ? " · по проекту" : string.Empty)}";
|
||
}
|
||
|
||
private List<RouteExportGroup> BuildGroups(IEnumerable<TemplateRouteStep> steps, string organization, PortalDirectory directory)
|
||
{
|
||
var ordered = steps.OrderBy(s => s.OrderNo).ToList();
|
||
var approvers = ordered.Where(s => s.Status != RouteStepStatus.Execution).ToList();
|
||
var executors = ordered.Where(s => s.Status == RouteStepStatus.Execution).ToList();
|
||
|
||
return new[]
|
||
{
|
||
new RouteExportGroup("Согласующие", BuildRows(approvers, organization, directory)),
|
||
new RouteExportGroup("Исполнители", BuildRows(executors, organization, directory))
|
||
}
|
||
.Where(g => g.Rows.Count > 0)
|
||
.ToList();
|
||
}
|
||
|
||
// Параллельные шаги (равный OrderNo) получают один и тот же номер.
|
||
private List<RouteExportRow> BuildRows(IReadOnlyList<TemplateRouteStep> steps, string organization, PortalDirectory directory)
|
||
{
|
||
var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList();
|
||
|
||
return steps
|
||
.Select(step =>
|
||
{
|
||
var (role, executor) = DescribeRoleAndExecutor(step, directory);
|
||
return new RouteExportRow(
|
||
orderNos.IndexOf(step.OrderNo) + 1,
|
||
role,
|
||
executor,
|
||
organization,
|
||
DescribeCondition(step),
|
||
DescribeComment(step));
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
// «Роль» — должность или тип руководителя, «исполнитель» — конкретный человек,
|
||
// если он известен уже при настройке маршрута. Директор Tansu и Управляющий
|
||
// директор Tansu — тоже конкретные люди (их email лежит в настройках).
|
||
private (string Role, string Executor) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||
{
|
||
var employee = directory.FindByEmail(step.ExecutorEmail);
|
||
return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(step.Position))
|
||
return (step.Position, directory.FindByPosition(step.Position)?.Fio ?? Empty);
|
||
|
||
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
|
||
return (ProjectRoleLabels[projectRole], ByRequest);
|
||
|
||
if (step.HeadType is { } headType && headType != HeadType.None)
|
||
{
|
||
var fixedEmail = headType switch
|
||
{
|
||
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||
_ => null
|
||
};
|
||
if (fixedEmail is not null)
|
||
// Нет в справочнике — лучше показать email, чем пустую ячейку.
|
||
return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail);
|
||
|
||
return (HeadTypeLabels[headType], ByRequest);
|
||
}
|
||
|
||
return (Empty, Empty);
|
||
}
|
||
|
||
private static string DescribeCondition(TemplateRouteStep step)
|
||
{
|
||
if (step.Conditions.Count == 0)
|
||
return Always;
|
||
|
||
var joiner = step.ConditionsLogic == ConditionsLogic.Or ? " ИЛИ " : " И ";
|
||
return string.Join(joiner, step.Conditions.OrderBy(c => c.OrderNo).Select(c =>
|
||
{
|
||
var field = ConditionFieldAliases.GetValueOrDefault(c.FieldName, c.FieldName);
|
||
var symbol = OperatorSymbols.GetValueOrDefault(c.Operation, string.Empty);
|
||
return $"{field} {symbol} {c.Value}";
|
||
}));
|
||
}
|
||
|
||
// Галочка «можно переназначить» — шаг может передать другому: согласующий
|
||
// назначает согласующего, исполнитель — исполнителя.
|
||
private static string DescribeComment(TemplateRouteStep step) =>
|
||
!step.AllowReassignToOtherApprovers
|
||
? string.Empty
|
||
: step.Status == RouteStepStatus.Execution ? "назначает исполнителя" : "назначает согласующего";
|
||
|
||
private static string EmptyIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? Empty : value;
|
||
|
||
private sealed class PortalDirectory(
|
||
IReadOnlyList<EmployeeContactInfo> employees,
|
||
IReadOnlyDictionary<Guid, string> companies,
|
||
IReadOnlyDictionary<Guid, string> departments)
|
||
{
|
||
// Справочник даёт строку на каждую компанию сотрудника — берём первую с
|
||
// заполненной должностью, остальные для ФИО/должности ничего не добавляют.
|
||
public EmployeeContactInfo? FindByEmail(string email) =>
|
||
employees.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase))
|
||
.OrderByDescending(e => !string.IsNullOrWhiteSpace(e.Position))
|
||
.FirstOrDefault();
|
||
|
||
public EmployeeContactInfo? FindByPosition(string position) =>
|
||
employees.FirstOrDefault(e => string.Equals(e.Position?.Trim(), position.Trim(), StringComparison.OrdinalIgnoreCase));
|
||
|
||
// Пока справочник не отдал имя (или запись удалена) — усечённый Guid, как в админке.
|
||
public string CompanyName(Guid id) => companies.GetValueOrDefault(id) ?? ShortGuid(id);
|
||
|
||
public string DepartmentName(Guid id) => departments.GetValueOrDefault(id) ?? ShortGuid(id);
|
||
|
||
private static string ShortGuid(Guid id) => id.ToString()[..8];
|
||
}
|
||
}
|