547c51ca3c
В сообщениях «не найден руководитель типа DirectionHead» и в конфликте ролей шага пользователь видел имена enum'ов. Теперь везде русские названия из общего справочника StepLabels (его же использует выгрузка маршрутов): «Не найден руководитель «Руководитель направления»: не указан у инициатора в справочнике сотрудников». Для директора Tansu и управляющего директора, которые берутся из настроек сервиса, — «Не проставлен «…»: email не задан в настройках сервиса». Так же исправлены ошибки о дубле шага с типом руководителя. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
295 lines
14 KiB
C#
295 lines
14 KiB
C#
using FluentResults;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Options;
|
||
using Tnsu.ApprovalRoute.Application.Common;
|
||
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>Готовый файл выгрузки и его имя.</summary>
|
||
public sealed record RouteExportFile(byte[] Content, string FileName);
|
||
|
||
/// <summary>
|
||
/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов
|
||
/// заявок, превращает их в понятные человеку строки (роль, исполнитель,
|
||
/// организация, условие, комментарий) и отдаёт на вёрстку в
|
||
/// <see cref="RouteExportWorkbook"/>.
|
||
///
|
||
/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага,
|
||
/// <see cref="RouteStepStatus"/>), у каждой своя нумерация.
|
||
/// </summary>
|
||
|
||
public sealed class RouteExportService(
|
||
ApprovalRouteDbContext db,
|
||
ITnsuPortalClient portalClient,
|
||
IOptions<TnsuPortalOptions> portalOptions)
|
||
{
|
||
private const int MaxTypesInFileName = 7;
|
||
private const int MaxTypeNameLength = 20;
|
||
|
||
private const string Empty = "—";
|
||
private const string Always = "Всегда";
|
||
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<ComparisonOperator, string> OperatorSymbols = new()
|
||
{
|
||
[ComparisonOperator.GreaterThan] = ">",
|
||
[ComparisonOperator.LessThan] = "<",
|
||
[ComparisonOperator.Equal] = "=",
|
||
[ComparisonOperator.NotEqual] = "≠",
|
||
[ComparisonOperator.Contains] = "содержит",
|
||
[ComparisonOperator.NotContains] = "не содержит"
|
||
};
|
||
|
||
public async Task<Result<RouteExportFile>> 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)
|
||
// Две вложенные коллекции (шаги и их условия) одним запросом дали бы декартово
|
||
// произведение строк — режем на отдельные запросы.
|
||
.AsSplitQuery()
|
||
.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(new RouteExportFile(
|
||
RouteExportWorkbook.Build(sheets),
|
||
FileName(types.Select(t => t.Name).ToList(), DateOnly.FromDateTime(DateTime.Now))));
|
||
}
|
||
|
||
/// <summary>
|
||
/// «Маршруты согласования (подбор, прием на работу) 13.10.2026.xlsx» — в скобках
|
||
/// сокращённые названия типов (не больше <see cref="MaxTypesInFileName"/>, остальные
|
||
/// сворачиваются в «и др.»), в конце — дата выгрузки.
|
||
/// </summary>
|
||
public static string FileName(IReadOnlyList<string> typeNames, DateOnly date)
|
||
{
|
||
var shown = typeNames.Take(MaxTypesInFileName).Select(Abbreviate).Where(n => n.Length > 0).ToList();
|
||
var list = string.Join(", ", shown) + (typeNames.Count > MaxTypesInFileName ? " и др." : string.Empty);
|
||
|
||
var name = $"Маршруты согласования ({list}) {date:dd.MM.yyyy}.xlsx";
|
||
return string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? ' ' : c));
|
||
}
|
||
|
||
// «Заявка на подбор персонала» → «подбор персонала»: служебное начало названия
|
||
// ничего не говорит, а длинное название раздуло бы имя файла.
|
||
private static string Abbreviate(string typeName)
|
||
{
|
||
var name = typeName.Trim();
|
||
foreach (var prefix in new[] { "Заявка на ", "Заявка о ", "Заявка " })
|
||
{
|
||
if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
name = name[prefix.Length..];
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (name.Length <= MaxTypeNameLength)
|
||
return name;
|
||
|
||
var cut = name[..MaxTypeNameLength];
|
||
var lastSpace = cut.LastIndexOf(' ');
|
||
return (lastSpace > MaxTypeNameLength / 2 ? cut[..lastSpace] : cut).TrimEnd() + "…";
|
||
}
|
||
|
||
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 =>
|
||
{
|
||
return new RouteExportSection(
|
||
routes.Count < 2 ? null : RouteTitle(route, directory),
|
||
BuildGroups(route.Steps, 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, 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, directory)),
|
||
new RouteExportGroup("Исполнители", BuildRows(executors, directory))
|
||
}
|
||
.Where(g => g.Rows.Count > 0)
|
||
.ToList();
|
||
}
|
||
|
||
// Параллельные шаги (равный OrderNo) получают один и тот же номер.
|
||
private List<RouteExportRow> BuildRows(IReadOnlyList<TemplateRouteStep> steps, PortalDirectory directory)
|
||
{
|
||
var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList();
|
||
|
||
return steps
|
||
.Select(step =>
|
||
{
|
||
var (role, executor, organization) = 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, string Organization) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||
{
|
||
var employee = directory.FindByEmail(step.ExecutorEmail);
|
||
return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail, directory.CompaniesOf(step.ExecutorEmail));
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(step.Position))
|
||
{
|
||
var holder = directory.FindByPosition(step.Position);
|
||
return (step.Position, holder?.Fio ?? Empty, directory.CompaniesOf(holder?.Email));
|
||
}
|
||
|
||
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
|
||
return (StepLabels.Of(projectRole), ByRequest, Empty);
|
||
|
||
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 (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
|
||
|
||
return (StepLabels.Of(headType), ByRequest, Empty);
|
||
}
|
||
|
||
return (Empty, 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));
|
||
|
||
// Организация — компания СОТРУДНИКА, а не маршрута. Справочник даёт строку на
|
||
// каждую компанию, где он числится (совместители), поэтому выводим все.
|
||
public string CompaniesOf(string? email)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(email))
|
||
return Empty;
|
||
|
||
var names = employees
|
||
.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase)
|
||
&& !string.IsNullOrWhiteSpace(e.Company))
|
||
.Select(e => e.Company)
|
||
.Distinct()
|
||
.ToList();
|
||
return names.Count == 0 ? Empty : string.Join(", ", names);
|
||
}
|
||
|
||
// Пока справочник не отдал имя (или запись удалена) — усечённый 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];
|
||
}
|
||
}
|