Feature/routes export #29
@@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthPolicies.Any)]
|
||||
[Route("api/reports")]
|
||||
public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase
|
||||
public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase
|
||||
{
|
||||
private const string XlsxContentType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
@@ -54,19 +54,19 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll
|
||||
|
||||
/// <summary>
|
||||
/// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип).
|
||||
/// POST, потому что строки собирает админка — у неё уже есть справочники
|
||||
/// сотрудников и подписи шагов (см. RouteExportWorkbook).
|
||||
/// POST — потому что в теле список id типов (см. RouteExportService).
|
||||
/// </summary>
|
||||
[HttpPost("routes")]
|
||||
[Produces(XlsxContentType)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[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 })
|
||||
return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } });
|
||||
var result = await routeExport.BuildAsync(request.RequestTypeIds, ct);
|
||||
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");
|
||||
disposition.SetHttpFileName(RouteExportWorkbook.FileName);
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddScoped<AutoApproveService>();
|
||||
services.AddScoped<AutoApproveJob>();
|
||||
services.AddScoped<VacancyReportService>();
|
||||
services.AddScoped<RouteExportService>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
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;
|
||||
|
||||
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка).</summary>
|
||||
public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition);
|
||||
/// <summary>Строка выгрузки маршрута: шаг в понятном человеку виде.</summary>
|
||||
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>
|
||||
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportRow> Rows);
|
||||
public sealed record RouteExportSection(string? Title, IReadOnlyList<RouteExportGroup> Groups);
|
||||
|
||||
/// <summary>Лист выгрузки — один тип заявки.</summary>
|
||||
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>
|
||||
/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками:
|
||||
/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие».
|
||||
///
|
||||
/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки:
|
||||
/// там уже есть справочники сотрудников и логика описания шага, дублировать их
|
||||
/// на бэкенде незачем. Здесь только вёрстка файла.
|
||||
/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает
|
||||
/// <see cref="RouteExportService"/>.
|
||||
/// </summary>
|
||||
public static class RouteExportWorkbook
|
||||
{
|
||||
@@ -37,12 +46,14 @@ public static class RouteExportWorkbook
|
||||
(" ", 6.0),
|
||||
("Роль ", 41.1),
|
||||
("Исполнитель ", 42.2),
|
||||
("Организация ", 28.0),
|
||||
("Условие ", 66.2),
|
||||
("Комментарий ", 36.0),
|
||||
];
|
||||
|
||||
public const string FileName = "Маршруты согласования.xlsx";
|
||||
|
||||
public static byte[] Build(RouteExportRequest request)
|
||||
public static byte[] Build(IReadOnlyList<RouteExportSheet> sheets)
|
||||
{
|
||||
var workbook = new XSSFWorkbook();
|
||||
|
||||
@@ -51,7 +62,7 @@ public static class RouteExportWorkbook
|
||||
var titleStyle = CreateStyle(workbook, bold: true, border: false);
|
||||
|
||||
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));
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
@@ -60,8 +71,6 @@ public static class RouteExportWorkbook
|
||||
var rowIndex = 0;
|
||||
foreach (var section in sheetData.Sections)
|
||||
{
|
||||
if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(section.Title))
|
||||
{
|
||||
var titleRow = sheet.CreateRow(rowIndex);
|
||||
@@ -72,23 +81,37 @@ public static class RouteExportWorkbook
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
var header = sheet.CreateRow(rowIndex++);
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
foreach (var group in section.Groups)
|
||||
{
|
||||
var cell = header.CreateCell(i);
|
||||
cell.SetCellValue(Columns[i].Title);
|
||||
cell.CellStyle = headerStyle;
|
||||
}
|
||||
var captionRow = sheet.CreateRow(rowIndex);
|
||||
var captionCell = captionRow.CreateCell(0);
|
||||
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 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.Condition, cellStyle);
|
||||
var header = sheet.CreateRow(rowIndex++);
|
||||
for (var i = 0; i < Columns.Length; i++)
|
||||
{
|
||||
var cell = header.CreateCell(i);
|
||||
cell.SetCellValue(Columns[i].Title);
|
||||
cell.CellStyle = headerStyle;
|
||||
}
|
||||
|
||||
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) =>
|
||||
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) =>
|
||||
GetOrAddAsync(
|
||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||
|
||||
@@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient(
|
||||
|
||||
return employees
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient(
|
||||
[property: JsonPropertyName("fio")] string? Fio,
|
||||
[property: JsonPropertyName("id")] Guid? Id = null);
|
||||
|
||||
private sealed record DictionaryItemResponse(
|
||||
[property: JsonPropertyName("id")] Guid? Id,
|
||||
[property: JsonPropertyName("name")] string? Name);
|
||||
|
||||
private sealed record ManagerResponse(
|
||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ public interface ITnsuPortalClient
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company,
|
||||
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
||||
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
||||
/// </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.tab === 'routesExport' && <RoutesExportScreen api={api} dictionary={dictionary} />}
|
||||
{route.tab === 'routesExport' && <RoutesExportScreen api={api} />}
|
||||
{route.tab === 'manageRequests' && (
|
||||
<ManageRequestsScreen api={api} dictionary={dictionary} requestManage={requestManage} route={route.manage} />
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { ApprovalRouteApi } from '../../services/api';
|
||||
import { PortalDictionaryClient } from '../../services/portalDictionary';
|
||||
import { texts } from '../../texts';
|
||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||
import { reportError } from '../../utils/errors';
|
||||
import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport';
|
||||
import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui';
|
||||
|
||||
export interface IRoutesExportScreenProps {
|
||||
api: ApprovalRouteApi;
|
||||
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
|
||||
dictionary?: PortalDictionaryClient;
|
||||
}
|
||||
|
||||
// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно
|
||||
@@ -31,10 +25,8 @@ function download(blob: Blob, fileName: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, dictionary }) => {
|
||||
export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api }) => {
|
||||
const requestTypes = useRequestTypes(api);
|
||||
const employees = usePortalEmployees(dictionary);
|
||||
const portalDictionaries = usePortalDictionaries(dictionary);
|
||||
|
||||
const [storedSelected, setSelected] = useLocalStorageState<number[]>(SELECTED_STORAGE_KEY, []);
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
@@ -51,27 +43,12 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
|
||||
const runExport = (): void => {
|
||||
setError(undefined);
|
||||
setIsBusy(true);
|
||||
// Порядок листов — как в списке типов, а не в порядке кликов.
|
||||
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 }))
|
||||
api.reports.routes(selected)
|
||||
.then(blob => download(blob, texts.routesExport.fileName))
|
||||
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
|
||||
.then(() => setIsBusy(false), () => setIsBusy(false));
|
||||
};
|
||||
|
||||
// Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться.
|
||||
const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.routesExport.breadcrumb }]} />
|
||||
@@ -83,7 +60,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api, di
|
||||
<Button
|
||||
variant="accent"
|
||||
icon="check"
|
||||
disabled={selected.length === 0 || isBusy || dictionariesLoading}
|
||||
disabled={selected.length === 0 || isBusy}
|
||||
onClick={runExport}
|
||||
>
|
||||
{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>}
|
||||
{employees.error && <Alert kind="err">{employees.error}</Alert>}
|
||||
{error && <Alert kind="err">{error}</Alert>}
|
||||
|
||||
<Panel>
|
||||
|
||||
@@ -9,4 +9,3 @@ export * from './templateRouteSteps';
|
||||
export * from './resolve';
|
||||
export * from './requestManage';
|
||||
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,
|
||||
PatchTemplateRouteStepInput,
|
||||
RequestListPartition,
|
||||
RouteExportRequest,
|
||||
RequestType,
|
||||
RequestTypeAccess,
|
||||
RequestTypeListSeries,
|
||||
@@ -176,7 +175,7 @@ export class ApprovalRouteApi {
|
||||
|
||||
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
|
||||
public readonly reports = {
|
||||
routes: (body: RouteExportRequest): Promise<Blob> =>
|
||||
this.http.postForBlob('/api/reports/routes', body)
|
||||
routes: (requestTypeIds: number[]): Promise<Blob> =>
|
||||
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,16 +211,12 @@ export const texts = {
|
||||
breadcrumb: 'Выгрузка маршрутов',
|
||||
subtitle: 'Маршруты согласования в Excel',
|
||||
title: 'Выгрузка маршрутов',
|
||||
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).',
|
||||
hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (согласующие и исполнители отдельными таблицами).',
|
||||
selectAll: 'Выбрать все',
|
||||
export: 'Выгрузить',
|
||||
exporting: 'Формирование файла...',
|
||||
emptyTypes: 'Типов заявок пока нет.',
|
||||
selected: (count: number): string => `Выбрано: ${count}`,
|
||||
routeTitle: (company: string, department: string, byProject: boolean): string =>
|
||||
`Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`,
|
||||
always: 'Всегда',
|
||||
executorByRequest: 'Определяется по заявке',
|
||||
fileName: 'Маршруты согласования.xlsx',
|
||||
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) }))
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user