Выгрузка маршрутов: сборка таблицы перенесена на бэкенд, добавлены организация, комментарий и деление на согласующих/исполнителей

Бэкенд сам читает маршруты, шаги и справочники портала (сотрудники с должностями,
компании, отделы), фронт шлёт только id типов заявок и скачивает файл.
Колонки: №, Роль, Исполнитель, Организация, Условие, Комментарий.
Согласующие и исполнители — отдельными таблицами. Условие по CompaniesJson
показывается как «организация». Директор Tansu и Управляющий директор Tansu
выводятся с ФИО. Комментарий «назначает согласующего/исполнителя» — при галочке
переназначения.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-13 10:24:18 +05:00
parent 43b3c77ac1
commit 6433299c62
15 changed files with 357 additions and 167 deletions
@@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController] [ApiController]
[Authorize(Policy = AuthPolicies.Any)] [Authorize(Policy = AuthPolicies.Any)]
[Route("api/reports")] [Route("api/reports")]
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
{ {
private const string XlsxContentType = private const string XlsxContentType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
@@ -54,19 +54,19 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
/// <summary> /// <summary>
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
/// POST, потому что строки собирает админка — у неё уже есть справочники /// POST — потому что в теле список id типов (см. RouteExportService).
/// сотрудников и подписи шагов (см. RouteExportWorkbook).
/// </summary> /// </summary>
[HttpPost("routes")] [HttpPost("routes")]
[Produces(XlsxContentType)] [Produces(XlsxContentType)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult Routes([FromBody] RouteExportRequest request) public async Task<IActionResult> Routes([FromBody] RouteExportRequest request, CancellationToken ct = default)
{ {
if (request.Sheets is not { Count: > 0 }) var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } }); if (result.IsFailed)
return BadRequest(new { errors = result.Errors.Select(e => e.Message) });
var file = RouteExportWorkbook.Build(request); var file = result.Value;
var disposition = new ContentDispositionHeaderValue("attachment"); var disposition = new ContentDispositionHeaderValue("attachment");
disposition.SetHttpFileName(RouteExportWorkbook.FileName); disposition.SetHttpFileName(RouteExportWorkbook.FileName);
@@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<AutoApproveService>(); services.AddScoped<AutoApproveService>();
services.AddScoped<AutoApproveJob>(); services.AddScoped<AutoApproveJob>();
services.AddScoped<VacancyReportService>(); services.AddScoped<VacancyReportService>();
services.AddScoped<RouteExportService>();
services.AddScoped<TemplateRouteService>(); services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>(); services.AddScoped<TemplateRouteStepService>();
@@ -0,0 +1,249 @@
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];
}
}
@@ -4,28 +4,37 @@ using NPOI.XSSF.UserModel;
namespace Tnsu.ApprovalRoute.Application.Reports; namespace Tnsu.ApprovalRoute.Application.Reports;
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка).</summary> /// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition); public sealed record RouteExportRow(
int Number,
string Role,
string Executor,
string Organization,
string Condition,
string Comment);
/// <summary>Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация).</summary>
public sealed record RouteExportGroup(string Caption, IReadOnlyList<RouteExportRow> Rows);
/// <summary> /// <summary>
/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов
/// (по компании/отделу) и их нужно различать на одном листе; у единственного /// (по компании/отделу) и их нужно различать на одном листе; у единственного
/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования».
/// </summary> /// </summary>
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportRow> Rows); public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
/// <summary>Лист выгрузки — один тип заявки.</summary> /// <summary>Лист выгрузки — один тип заявки.</summary>
public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections); public sealed record RouteExportSheet(string Name, IReadOnlyList<RouteExportSection> Sections);
public sealed record RouteExportRequest(IReadOnlyList<RouteExportSheet> Sheets); /// <summary>Тело запроса POST api/reports/routes — какие типы заявок выгрузить.</summary>
public sealed record RouteExportRequest(IReadOnlyList<int> RequestTypeIds);
/// <summary> /// <summary>
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: /// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». /// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
/// ///
/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: /// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
/// там уже есть справочники сотрудников и логика описания шага, дублировать их /// <see cref="RouteExportService"/>.
/// на бэкенде незачем. Здесь только вёрстка файла.
/// </summary> /// </summary>
public static class RouteExportWorkbook public static class RouteExportWorkbook
{ {
@@ -37,12 +46,14 @@ public static class RouteExportWorkbook
(" ", 6.0), (" ", 6.0),
("Роль ", 41.1), ("Роль ", 41.1),
("Исполнитель ", 42.2), ("Исполнитель ", 42.2),
("Организация ", 28.0),
("Условие ", 66.2), ("Условие ", 66.2),
("Комментарий ", 36.0),
]; ];
public const string FileName = "Маршруты согласования.xlsx"; public const string FileName = "Маршруты согласования.xlsx";
public static byte[] Build(RouteExportRequest request) public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
{ {
var workbook = new XSSFWorkbook(); var workbook = new XSSFWorkbook();
@@ -51,7 +62,7 @@ public static class RouteExportWorkbook
var titleStyle = CreateStyle(workbook, bold: true, border: false); var titleStyle = CreateStyle(workbook, bold: true, border: false);
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var sheetData in request.Sheets) foreach (var sheetData in sheets)
{ {
var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames));
for (var i = 0; i < Columns.Length; i++) for (var i = 0; i < Columns.Length; i++)
@@ -60,8 +71,6 @@ public static class RouteExportWorkbook
var rowIndex = 0; var rowIndex = 0;
foreach (var section in sheetData.Sections) foreach (var section in sheetData.Sections)
{ {
if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами
if (!string.IsNullOrWhiteSpace(section.Title)) if (!string.IsNullOrWhiteSpace(section.Title))
{ {
var titleRow = sheet.CreateRow(rowIndex); var titleRow = sheet.CreateRow(rowIndex);
@@ -72,23 +81,37 @@ public static class RouteExportWorkbook
rowIndex++; rowIndex++;
} }
var header = sheet.CreateRow(rowIndex++); foreach (var group in section.Groups)
for (var i = 0; i < Columns.Length; i++)
{ {
var cell = header.CreateCell(i); var captionRow = sheet.CreateRow(rowIndex);
cell.SetCellValue(Columns[i].Title); var captionCell = captionRow.CreateCell(0);
cell.CellStyle = headerStyle; captionCell.SetCellValue(group.Caption);
} captionCell.CellStyle = titleStyle;
sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1));
rowIndex++;
foreach (var row in section.Rows) var header = sheet.CreateRow(rowIndex++);
{ for (var i = 0; i < Columns.Length; i++)
var excelRow = sheet.CreateRow(rowIndex++); {
var numberCell = excelRow.CreateCell(0); var cell = header.CreateCell(i);
numberCell.SetCellValue(row.Number); cell.SetCellValue(Columns[i].Title);
numberCell.CellStyle = cellStyle; cell.CellStyle = headerStyle;
WriteText(excelRow, 1, row.Role, cellStyle); }
WriteText(excelRow, 2, row.Executor, cellStyle);
WriteText(excelRow, 3, row.Condition, cellStyle); foreach (var row in group.Rows)
{
var excelRow = sheet.CreateRow(rowIndex++);
var numberCell = excelRow.CreateCell(0);
numberCell.SetCellValue(row.Number);
numberCell.CellStyle = cellStyle;
WriteText(excelRow, 1, row.Role, cellStyle);
WriteText(excelRow, 2, row.Executor, cellStyle);
WriteText(excelRow, 3, row.Organization, cellStyle);
WriteText(excelRow, 4, row.Condition, cellStyle);
WriteText(excelRow, 5, row.Comment, cellStyle);
}
rowIndex++; // пустая строка между таблицами
} }
} }
} }
@@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) => public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken)); GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
public Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken));
public Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken) =>
GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken));
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync( GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
return employees return employees
.Where(e => !string.IsNullOrWhiteSpace(e.Fio)) .Where(e => !string.IsNullOrWhiteSpace(e.Fio))
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty)) .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty, e.Position))
.ToList();
}
// /Dictionary/* отдаёт «голый» массив с ключами в PascalCase (Id/Name) —
// PropertyNameCaseInsensitive в JsonOptions это покрывает.
public async Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken)
{
var companies = await GetAsync<List<DictionaryItemResponse>>("/Dictionary/Companies", cancellationToken);
return (companies ?? [])
.Where(c => c.Id is not null && !string.IsNullOrWhiteSpace(c.Name))
.Select(c => new CompanyInfo(c.Id!.Value, c.Name!))
.ToList();
}
public async Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken)
{
var departments = await GetAsync<List<DictionaryItemResponse>>("/Dictionary/Departments", cancellationToken);
return (departments ?? [])
.Where(d => d.Id is not null && !string.IsNullOrWhiteSpace(d.Name))
.Select(d => new DepartmentInfo(d.Id!.Value, d.Name!))
.ToList(); .ToList();
} }
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
[property: JsonPropertyName("fio")] string? Fio, [property: JsonPropertyName("fio")] string? Fio,
[property: JsonPropertyName("id")] Guid? Id = null); [property: JsonPropertyName("id")] Guid? Id = null);
private sealed record DictionaryItemResponse(
[property: JsonPropertyName("id")] Guid? Id,
[property: JsonPropertyName("name")] string? Name);
private sealed record ManagerResponse( private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail); [property: JsonPropertyName("managerEmail")] string? ManagerEmail);
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
/// </summary> /// </summary>
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken); Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
/// <summary>Справочник компаний — /Dictionary/Companies.</summary>
Task<IReadOnlyList<CompanyInfo>> GetCompaniesAsync(CancellationToken cancellationToken);
/// <summary>Справочник отделов — /Dictionary/Departments.</summary>
Task<IReadOnlyList<DepartmentInfo>> GetDepartmentsAsync(CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
/// <summary> /// <summary>
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно /// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
/// по <paramref name="Id"/> — он один и тот же в обоих ответах. /// по <paramref name="Id"/> — он один и тот же в обоих ответах.
/// </summary> /// </summary>
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company); public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company, string? Position = null);
/// <summary>Компания из справочника /Dictionary/Companies.</summary>
public sealed record CompanyInfo(Guid Id, string Name);
/// <summary>Отдел из справочника /Dictionary/Departments.</summary>
public sealed record DepartmentInfo(Guid Id, string Name);
@@ -186,7 +186,7 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint
route={route.resolve} route={route.resolve}
/> />
)} )}
{route.tab === 'routesExport' && <RoutesExportScreen api={api} dictionary={dictionary} />} {route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
{route.tab === 'manageRequests' && ( {route.tab === 'manageRequests' && (
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} /> <ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
)} )}
@@ -1,19 +1,13 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
import { reportError } from '../../utils/errors'; import { reportError } from '../../utils/errors';
import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport';
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
export interface IRoutesExportScreenProps { export interface IRoutesExportScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient;
} }
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно // Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
@@ -31,10 +25,8 @@ function download(blob: Blob, fileName: string): void {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, dictionary }) => { export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
const requestTypes = useRequestTypes(api); const requestTypes = useRequestTypes(api);
const employees = usePortalEmployees(dictionary);
const portalDictionaries = usePortalDictionaries(dictionary);
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []); const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
const [isBusy, setIsBusy] = React.useState(false); const [isBusy, setIsBusy] = React.useState(false);
@@ -51,27 +43,12 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
const runExport = (): void => { const runExport = (): void => {
setError(undefined); setError(undefined);
setIsBusy(true); setIsBusy(true);
// Порядок листов — как в списке типов, а не в порядке кликов. api.reports.routes(selected)
const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1);
Promise.all(
types.map(async rt => {
const routes = await api.templateRoutes.list(rt.id);
const items: RouteWithSteps[] = await Promise.all(
routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) }))
);
return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments);
})
)
.then(sheets => api.reports.routes({ sheets }))
.then(blob => download(blob, texts.routesExport.fileName)) .then(blob => download(blob, texts.routesExport.fileName))
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
.then(() => setIsBusy(false), () => setIsBusy(false)); .then(() => setIsBusy(false), () => setIsBusy(false));
}; };
// Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться.
const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading;
return ( return (
<div> <div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} /> <Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
@@ -83,7 +60,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
<Button <Button
variant="accent" variant="accent"
icon="check" icon="check"
disabled={selected.length === 0 || isBusy || dictionariesLoading} disabled={selected.length === 0 || isBusy}
onClick={runExport} onClick={runExport}
> >
{isBusy ? texts.routesExport.exporting : texts.routesExport.export} {isBusy ? texts.routesExport.exporting : texts.routesExport.export}
@@ -92,7 +69,6 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
/> />
{requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>} {requestTypes.error && <Alert kind="err">{requestTypes.error}</Alert>}
{employees.error && <Alert kind="err">{employees.error}</Alert>}
{error && <Alert kind="err">{error}</Alert>} {error && <Alert kind="err">{error}</Alert>}
<Panel> <Panel>
@@ -9,4 +9,3 @@ export * from './templateRouteSteps';
export * from './resolve'; export * from './resolve';
export * from './requestManage'; export * from './requestManage';
export * from './manageByUser'; export * from './manageByUser';
export * from './routeExport';
@@ -1,24 +0,0 @@
// DTO эндпоинта POST api/reports/routes (Application/Reports/RouteExportWorkbook.cs).
export interface RouteExportRow {
number: number;
role: string;
executor: string;
condition: string;
}
export interface RouteExportSection {
/** Заголовок маршрута — только когда у типа заявки их несколько. */
title?: string;
rows: RouteExportRow[];
}
/** Лист файла — один тип заявки. */
export interface RouteExportSheet {
name: string;
sections: RouteExportSection[];
}
export interface RouteExportRequest {
sheets: RouteExportSheet[];
}
@@ -13,7 +13,6 @@ import {
PatchTemplateRouteInput, PatchTemplateRouteInput,
PatchTemplateRouteStepInput, PatchTemplateRouteStepInput,
RequestListPartition, RequestListPartition,
RouteExportRequest,
RequestType, RequestType,
RequestTypeAccess, RequestTypeAccess,
RequestTypeListSeries, RequestTypeListSeries,
@@ -176,7 +175,7 @@ export class ApprovalRouteApi {
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON. // Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
public readonly reports = { public readonly reports = {
routes: (body: RouteExportRequest): Promise<Blob> => routes: (requestTypeIds: number[]): Promise<Blob> =>
this.http.postForBlob('/api/reports/routes', body) this.http.postForBlob('/api/reports/routes', { requestTypeIds })
}; };
} }
+1 -5
View File
@@ -211,16 +211,12 @@ export const texts = {
breadcrumb: 'Выгрузка маршрутов', breadcrumb: 'Выгрузка маршрутов',
subtitle: 'Маршруты согласования в Excel', subtitle: 'Маршруты согласования в Excel',
title: 'Выгрузка маршрутов', title: 'Выгрузка маршрутов',
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).', hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).',
selectAll: 'Выбрать все', selectAll: 'Выбрать все',
export: 'Выгрузить', export: 'Выгрузить',
exporting: 'Формирование файла...', exporting: 'Формирование файла...',
emptyTypes: 'Типов заявок пока нет.', emptyTypes: 'Типов заявок пока нет.',
selected: (count: number): string => `Выбрано: ${count}`, selected: (count: number): string => `Выбрано: ${count}`,
routeTitle: (company: string, department: string, byProject: boolean): string =>
`Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`,
always: 'Всегда',
executorByRequest: 'Определяется по заявке',
fileName: 'Маршруты согласования.xlsx', fileName: 'Маршруты согласования.xlsx',
failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}` failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}`
}, },
@@ -1,71 +0,0 @@
import { Company, Department, Employee, HeadType, ProjectRole, RequestType, RouteExportRow, RouteExportSheet, TemplateRoute, TemplateRouteStep } from '../models';
import { headTypeLabels, projectRoleLabels, texts } from '../texts';
import { describeStepCondition } from './stepExecutor';
import { findEmployeeByEmail } from './employee';
import { shortGuid } from './guid';
export interface RouteWithSteps {
route: TemplateRoute;
steps: TemplateRouteStep[];
}
/**
* Роль и исполнитель шага для выгрузки — как в ручной таблице HR: «роль» это
* должность/тип руководителя, «исполнитель» — конкретный человек, если он
* известен уже на этапе настройки маршрута.
*/
function describeRoleAndExecutor(step: TemplateRouteStep, employees: Employee[]): { role: string; executor: string } {
if (step.executorEmail) {
const employee = findEmployeeByEmail(employees, step.executorEmail);
return { role: employee?.position || texts.common.empty, executor: employee?.fio ?? step.executorEmail };
}
if (step.position) {
const wanted = step.position.trim().toLowerCase();
const holder = employees.find(e => e.position.trim().toLowerCase() === wanted);
return { role: step.position, executor: holder?.fio ?? texts.common.empty };
}
if (step.projectRole && step.projectRole !== ProjectRole.None) {
return { role: projectRoleLabels[step.projectRole], executor: texts.routesExport.executorByRequest };
}
if (step.headType && step.headType !== HeadType.None) {
return { role: headTypeLabels[step.headType], executor: texts.routesExport.executorByRequest };
}
return { role: texts.common.empty, executor: texts.common.empty };
}
/** Строки листа: параллельные шаги (равный orderNo) получают один и тот же номер. */
export function buildRouteRows(steps: TemplateRouteStep[], employees: Employee[]): RouteExportRow[] {
const orderNos = Array.from(new Set(steps.map(s => s.orderNo))).sort((a, b) => a - b);
return [...steps]
.sort((a, b) => a.orderNo - b.orderNo)
.map(step => ({
number: orderNos.indexOf(step.orderNo) + 1,
...describeRoleAndExecutor(step, employees),
condition: describeStepCondition(step) ?? texts.routesExport.always
}));
}
/** Лист на тип заявки. Заголовки маршрутов нужны, только когда их у типа несколько. */
export function buildRouteSheet(
requestType: RequestType,
items: RouteWithSteps[],
employees: Employee[],
companies: Company[],
departments: Department[]
): RouteExportSheet {
const title = (route: TemplateRoute): string | undefined => {
if (items.length < 2) return undefined;
const company = route.companyId
? companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId)
: texts.routeSteps.anyCompany;
const department = route.departmentId
? departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId)
: texts.routeSteps.anyDepartment;
return texts.routesExport.routeTitle(company, department, route.byProject);
};
return {
name: requestType.name,
sections: items.map(i => ({ title: title(i.route), rows: buildRouteRows(i.steps, employees) }))
};
}