diff --git a/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs
new file mode 100644
index 0000000..5e92a8f
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Common/StepLabels.cs
@@ -0,0 +1,32 @@
+using Tnsu.ApprovalRoute.Domain.Enums;
+
+namespace Tnsu.ApprovalRoute.Application.Common;
+
+///
+/// Русские названия типов руководителя и ролей в проекте — для всего, что видит
+/// человек: предупреждения резолва, ошибки валидации, выгрузка маршрутов. Имя
+/// enum'а («DirectionHead») в тексте для пользователя недопустимо.
+///
+public static class StepLabels
+{
+ public static string Of(HeadType headType) => headType switch
+ {
+ HeadType.Chief => "Непосредственный руководитель",
+ HeadType.DirectionHead => "Руководитель направления",
+ HeadType.DirectorTansu => "Директор Tansu",
+ HeadType.ManagingDirectorTansu => "Управляющий директор Tansu",
+ HeadType.DepartmentHead => "Руководитель указанного отдела",
+ HeadType.CompanyDirectors => "Директора компаний инициатора",
+ _ => headType.ToString()
+ };
+
+ public static string Of(ProjectRole projectRole) => projectRole switch
+ {
+ ProjectRole.ProjectManager => "Руководитель проекта",
+ ProjectRole.Planner => "Планировщик",
+ ProjectRole.ManagerHSE => "Менеджер HSE",
+ ProjectRole.Security => "Безопасность",
+ ProjectRole.Keeper => "Заведующий склада",
+ _ => projectRole.ToString()
+ };
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs
index a8f04ff..785f5d7 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs
@@ -1,6 +1,7 @@
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;
@@ -44,25 +45,6 @@ public sealed class RouteExportService(
["CompaniesJson"] = "организация"
};
- private static readonly Dictionary ProjectRoleLabels = new()
- {
- [ProjectRole.ProjectManager] = "Руководитель проекта",
- [ProjectRole.Planner] = "Планировщик",
- [ProjectRole.ManagerHSE] = "Менеджер HSE",
- [ProjectRole.Security] = "Безопасность",
- [ProjectRole.Keeper] = "Заведующий склада"
- };
-
- private static readonly Dictionary HeadTypeLabels = new()
- {
- [HeadType.Chief] = "Непосредственный руководитель",
- [HeadType.DirectionHead] = "Руководитель направления",
- [HeadType.DirectorTansu] = "Директор Tansu",
- [HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu",
- [HeadType.DepartmentHead] = "Руководитель указанного отдела",
- [HeadType.CompanyDirectors] = "Директора компаний инициатора"
- };
-
private static readonly Dictionary OperatorSymbols = new()
{
[ComparisonOperator.GreaterThan] = ">",
@@ -228,7 +210,7 @@ public sealed class RouteExportService(
}
if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None)
- return (ProjectRoleLabels[projectRole], ByRequest, Empty);
+ return (StepLabels.Of(projectRole), ByRequest, Empty);
if (step.HeadType is { } headType && headType != HeadType.None)
{
@@ -240,9 +222,9 @@ public sealed class RouteExportService(
};
if (fixedEmail is not null)
// Нет в справочнике — лучше показать email, чем пустую ячейку.
- return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
+ return (StepLabels.Of(headType), directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail, directory.CompaniesOf(fixedEmail));
- return (HeadTypeLabels[headType], ByRequest, Empty);
+ return (StepLabels.Of(headType), ByRequest, Empty);
}
return (Empty, Empty, Empty);
diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs
index d1d9d29..f877a8a 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs
@@ -1,6 +1,7 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
+using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
@@ -82,7 +83,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
return Result.Fail(new ConflictError(
- $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
+ $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
var entity = new TemplateRouteStep
{
@@ -141,7 +142,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail(new ConflictError(
- $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
+ $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(r.HeadType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
@@ -210,7 +211,7 @@ public sealed class TemplateRouteStepService(
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
return Result.Fail(new ConflictError(
- $"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
+ $"В маршруте уже есть шаг с типом руководителя «{StepLabels.Of(headType!.Value)}» и таким же условием появления — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail;
diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
index 657f00d..16c686b 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using Tnsu.ApprovalRoute.Application.Common;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
@@ -437,7 +438,7 @@ public sealed class TemplateRouteService(
{
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
if (string.IsNullOrWhiteSpace(resolvedEmail))
- warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
+ warning = $"Не найден исполнитель с ролью в проекте «{StepLabels.Of(step.ProjectRole.Value)}».";
}
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
@@ -469,7 +470,14 @@ public sealed class TemplateRouteService(
_ => null
};
if (string.IsNullOrWhiteSpace(resolvedEmail))
- warning = $"Не найден руководитель типа «{step.HeadType}».";
+ {
+ var headLabel = StepLabels.Of(step.HeadType.Value);
+ // Директор Tansu и управляющий директор берутся из настроек сервиса, а не из
+ // справочника сотрудников — там email не «не найден», а «не проставлен».
+ warning = step.HeadType is HeadType.DirectorTansu or HeadType.ManagingDirectorTansu
+ ? $"Не проставлен «{headLabel}»: email не задан в настройках сервиса."
+ : $"Не найден руководитель «{headLabel}»: не указан у инициатора в справочнике сотрудников.";
+ }
}
else
{
@@ -612,7 +620,7 @@ public sealed class TemplateRouteService(
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
{
var conflictWarning =
- $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
+ $"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({StepLabels.Of(existing.PositionCode.Value)} и {StepLabels.Of(step.PositionCode.Value)}) — оставлен «{StepLabels.Of(existing.PositionCode.Value)}».";
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
result[index] = existing with { Warning = combinedWarning };
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
index b3c3857..8b7c2af 100644
--- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
@@ -361,8 +361,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture