Заглушка для департамента (#8)
Tests / frontend-test (push) Successful in 43s
Tests / backend-test (push) Successful in 2m57s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-07 10:36:53 +00:00
parent 0ffa032ade
commit 1d45780294
14 changed files with 369 additions and 14 deletions
@@ -56,6 +56,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: null, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим."));
var entity = new TemplateRouteStep
{
RouteId = route.Id,
@@ -105,6 +109,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
entity.Position = r.Position;
@@ -165,6 +173,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
if (await HasDuplicateHeadTypeAsync(route.Id, headType, excludeId: entity.Id, cancellationToken))
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
$"В маршруте уже есть шаг с типом руководителя «{headType}» — второй такой шаг недопустим."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail;
entity.Position = position;
@@ -208,6 +220,31 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
x.Status == status,
cancellationToken);
/// <summary>
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
/// DepartmentHead, единственный departmentId запроса (см.
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
/// в одного и того же (или неотличимо какого) руководителя — например, «два
/// разных директора одной и той же компании» просто не существуют. В отличие
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
/// достаточно самого факта совпадения HeadType — вне зависимости от статуса
/// или остальных полей шага.
/// </summary>
private Task<bool> HasDuplicateHeadTypeAsync(
int routeId, HeadType? headType, int? excludeId, CancellationToken cancellationToken)
{
if (headType is null or HeadType.None)
return Task.FromResult(false);
return db.TemplateRouteSteps.AnyAsync(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
x.HeadType == headType,
cancellationToken);
}
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{
@@ -18,9 +18,17 @@ public sealed record ResolveTemplateRouteResultDto(
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
public sealed record ResolveFieldValueDto(string FieldName, string Value);
/// <param name="DepartmentId">
/// Отдел для шагов с HeadType.DepartmentHead ("руководитель указанного отдела") —
/// не выводится из инициатора, поэтому должен быть передан явно. Необязателен:
/// если в маршруте нет такого шага, поле не нужно; если есть, а поле не
/// передано — шаг не резолвится и получает предупреждение (см.
/// TemplateRouteService.ResolveStepAsync), весь запрос при этом не падает.
/// </param>
public sealed record ResolveTemplateRouteRequest(
int RequestTypeId,
string? UserEmail,
Guid? ProjectId,
RouteStepStatus Status,
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null);
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null,
Guid? DepartmentId = null);
@@ -168,7 +168,7 @@ public sealed class TemplateRouteService(
var steps = new List<ResolvedRouteStepDto>();
foreach (var step in matchingSteps)
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken));
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, request.DepartmentId, cancellationToken));
var collapsedSteps = CollapseByExecutorEmail(steps);
@@ -212,7 +212,7 @@ public sealed class TemplateRouteService(
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, CancellationToken cancellationToken)
{
string? resolvedEmail;
string? warning = null;
@@ -233,6 +233,25 @@ public sealed class TemplateRouteService(
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
}
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
// ResolveTemplateRouteRequest.DepartmentId): отдел инициатора тут ни при
// чём, шаг про «руководителя ТАКОГО-ТО отдела», а не «моего руководителя».
// Поэтому у него отдельная ветка, а не место в switch чуть ниже.
else if (step.HeadType == HeadType.DepartmentHead)
{
if (departmentId is null)
{
resolvedEmail = null;
warning = "Шаг требует руководителя указанного отдела, но departmentId не передан в запросе.";
}
else
{
resolvedEmail = await portalClient.GetDepartmentHeadEmailAsync(departmentId.Value, cancellationToken);
if (string.IsNullOrWhiteSpace(resolvedEmail))
warning = $"Не найден руководитель отдела с id «{departmentId}».";
}
}
else if (step.HeadType is not (null or HeadType.None))
{
resolvedEmail = step.HeadType switch
@@ -5,5 +5,14 @@ public enum HeadType
None,
Chief,
DirectionHead,
DirectorTansu
DirectorTansu,
/// <summary>
/// Руководитель отдела, указанного явно в запросе на resolve (см.
/// ResolveTemplateRouteRequest.DepartmentId), а не отдела инициатора —
/// этим отличается от Chief/DirectionHead, которые всегда идут по цепочке
/// подчинённости самого инициатора. В маршруте не может быть больше одного
/// шага с этим типом (см. TemplateRouteStepService).
/// </summary>
DepartmentHead
}
@@ -56,6 +56,16 @@ public sealed class HttpTnsuPortalClient(
return executors;
}
// STUB(department-head): в api.tnsu.kz пока нет эндпоинта «руководитель отдела
// по Id отдела» — до его появления просто возвращаем захардкоженный email,
// чтобы можно было отладить всю остальную логику HeadType.DepartmentHead
// (resolve, warning-и, UI) не дожидаясь портала. Когда эндпоинт появится —
// заменить тело этого метода на реальный вызов через GetAsync<T> (по
// образцу остальных методов этого класса); сигнатуру и вызывающий код
// (TemplateRouteService.ResolveStepAsync) менять не придётся.
public Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) =>
Task.FromResult<string?>("gorbenko.pavel@tnsu.kz");
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
@@ -9,4 +9,7 @@ public interface ITnsuPortalClient
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken);
}
@@ -231,6 +231,115 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
}
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Department head without id");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action — departmentId в запросе не передан
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].Warning.ShouldContain("departmentId");
}
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» и переданным departmentId — резолвится через портал")]
public async Task ResolveAsync_DepartmentHeadStepWithDepartmentId_ResolvesViaPortalClient()
{
// Arrange
var targetDepartmentId = Guid.Parse("00000000-0000-0000-0000-0000000000aa");
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
portalClient
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync("head-of-department@tnsu.kz");
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Department head resolved", "/department-head-resolved", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, DepartmentId: targetDepartmentId),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("head-of-department@tnsu.kz");
resolved.Value.Steps[0].Warning.ShouldBeNull();
}
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела», для которого портал не вернул руководителя — предупреждение")]
public async Task ResolveAsync_DepartmentHeadNotFoundByPortal_ReturnsWarning()
{
// Arrange
var targetDepartmentId = Guid.Parse("00000000-0000-0000-0000-0000000000bb");
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
portalClient
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
.ReturnsAsync((string?)null);
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
var routeService = services.GetRequiredService<TemplateRouteService>();
var requestTypeService = services.GetRequiredService<RequestTypeService>();
var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync(
new CreateRequestTypeRequest("Department head not found", "/department-head-not-found", null, null, "General"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, DepartmentId: targetDepartmentId),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].Warning.ShouldContain(targetDepartmentId.ToString());
}
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep()
{
@@ -85,6 +85,92 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
duplicate.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")]
public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult()
{
// Arrange — тип руководителя резолвится в ОДНОГО конкретного человека
// (см. HasDuplicateHeadTypeAsync), поэтому два шага с одним HeadType в
// одном маршруте не должны допускаться, даже если остальные поля разные.
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
var second = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null),
CancellationToken.None);
// Assert
second.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Два шага «Руководитель указанного отдела» в одном маршруте — конфликт")]
public async Task CreateAsync_TwoDepartmentHeadSteps_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
var first = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action
var second = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, null, null),
CancellationToken.None);
// Assert
second.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Разные типы руководителя в одном маршруте — допустимы (конфликт только при совпадении HeadType)")]
public async Task CreateAsync_DifferentHeadTypes_BothSucceed()
{
// Arrange
var routeId = await CreateRouteAsync("Route with different head types", byProject: false);
// Action
var chief = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
var directorTansu = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Assert
chief.IsSuccess.ShouldBeTrue();
directorTansu.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Обновление шага, при котором HeadType не меняется, не конфликтует само с собой")]
public async Task UpdateAsync_SameHeadTypeOnSameStep_DoesNotConflictWithItself()
{
// Arrange
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
var updated = await _service.UpdateAsync(
routeId,
created.Value.Id,
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null),
CancellationToken.None);
// Assert
updated.IsSuccess.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
{