diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 034ecc5..62afa2f 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -56,6 +56,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: null, cancellationToken)) + return Result.Fail(new ConflictError( + $"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим.")); + var entity = new TemplateRouteStep { RouteId = route.Id, @@ -105,6 +109,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: entity.Id, cancellationToken)) + return Result.Fail(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(new ConflictError( "Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте.")); + if (await HasDuplicateHeadTypeAsync(route.Id, headType, excludeId: entity.Id, cancellationToken)) + return Result.Fail(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); + /// + /// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится + /// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для + /// DepartmentHead, единственный departmentId запроса (см. + /// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора. + /// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы + /// в одного и того же (или неотличимо какого) руководителя — например, «два + /// разных директора одной и той же компании» просто не существуют. В отличие + /// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь + /// достаточно самого факта совпадения HeadType — вне зависимости от статуса + /// или остальных полей шага. + /// + private Task 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 ToDtoWithWarningAsync( TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) { diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index 8492c1d..ceb8cd6 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -18,9 +18,17 @@ public sealed record ResolveTemplateRouteResultDto( /// Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve. public sealed record ResolveFieldValueDto(string FieldName, string Value); +/// +/// Отдел для шагов с HeadType.DepartmentHead ("руководитель указанного отдела") — +/// не выводится из инициатора, поэтому должен быть передан явно. Необязателен: +/// если в маршруте нет такого шага, поле не нужно; если есть, а поле не +/// передано — шаг не резолвится и получает предупреждение (см. +/// TemplateRouteService.ResolveStepAsync), весь запрос при этом не падает. +/// public sealed record ResolveTemplateRouteRequest( int RequestTypeId, string? UserEmail, Guid? ProjectId, RouteStepStatus Status, - IReadOnlyList? FieldValues = null); + IReadOnlyList? FieldValues = null, + Guid? DepartmentId = null); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 17766ad..8bdbaec 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -168,7 +168,7 @@ public sealed class TemplateRouteService( var steps = new List(); 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 ResolveStepAsync( TemplateRouteStep step, string initiatorEmail, string initiatorCompany, - IReadOnlyList projectExecutors, CancellationToken cancellationToken) + IReadOnlyList 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 diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs index a3e097d..13ae882 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -5,5 +5,14 @@ public enum HeadType None, Chief, DirectionHead, - DirectorTansu + DirectorTansu, + + /// + /// Руководитель отдела, указанного явно в запросе на resolve (см. + /// ResolveTemplateRouteRequest.DepartmentId), а не отдела инициатора — + /// этим отличается от Chief/DirectionHead, которые всегда идут по цепочке + /// подчинённости самого инициатора. В маршруте не может быть больше одного + /// шага с этим типом (см. TemplateRouteStepService). + /// + DepartmentHead } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 0a46479..0c46637 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -56,6 +56,16 @@ public sealed class HttpTnsuPortalClient( return executors; } + // STUB(department-head): в api.tnsu.kz пока нет эндпоинта «руководитель отдела + // по Id отдела» — до его появления просто возвращаем захардкоженный email, + // чтобы можно было отладить всю остальную логику HeadType.DepartmentHead + // (resolve, warning-и, UI) не дожидаясь портала. Когда эндпоинт появится — + // заменить тело этого метода на реальный вызов через GetAsync (по + // образцу остальных методов этого класса); сигнатуру и вызывающий код + // (TemplateRouteService.ResolveStepAsync) менять не придётся. + public Task GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) => + Task.FromResult("gorbenko.pavel@tnsu.kz"); + private async Task GetAsync(string requestUri, CancellationToken cancellationToken) where T : class { using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 4b5a5c6..502affa 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -9,4 +9,7 @@ public interface ITnsuPortalClient Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); + + /// Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден. + Task GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken); } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index a8eb088..8bbd080 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -231,6 +231,115 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + 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(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + portalClient + .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny())) + .ReturnsAsync("head-of-department@tnsu.kz"); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + 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(); + portalClient + .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); + portalClient + .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny())) + .ReturnsAsync((string?)null); + + var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); + var routeService = services.GetRequiredService(); + var requestTypeService = services.GetRequiredService(); + var stepService = services.GetRequiredService(); + + 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() { diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs index f9cdd30..c65ed41 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteStepServiceTests.cs @@ -85,6 +85,92 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture = ({ api, dictionary, sharePoint, currentUserEmail, route }) => { const requestTypes = useRequestTypes(api); const [form, setForm] = useLocalStorageState(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM); - const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form; + const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [], departmentId } = form; // Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда // перекрывают то, что было в localStorage. Только на монтировании: route @@ -85,7 +88,8 @@ export const ResolveScreen: React.FC = ({ api, dictionary, projectId: route.projectId, projectLabel: route.projectLabel, status: route.status, - fieldValues: route.fieldValues + fieldValues: route.fieldValues, + departmentId: route.departmentId }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -94,8 +98,11 @@ export const ResolveScreen: React.FC = ({ api, dictionary, // которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе // каждый введённый символ добавлял бы шаг в историю браузера. React.useEffect(() => { - replaceRoute({ tab: 'resolve', resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues } }); - }, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues]); + replaceRoute({ + tab: 'resolve', + resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues, departmentId } + }); + }, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues, departmentId]); const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId)); const hasListId = Boolean(requestType?.listId); @@ -104,6 +111,9 @@ export const ResolveScreen: React.FC = ({ api, dictionary, const resolved = useResolveRoute(api); const employees = usePortalEmployees(dictionary); + // ~700 отделов — то же соображение, что и в RouteFormModal: дешевле грузить + // список один раз и искать по нему на клиенте, чем ходить в сеть на каждый символ. + const dictionaries = usePortalDictionaries(dictionary); const searchProjects = React.useCallback( (query: string, page: number) => { @@ -143,7 +153,8 @@ export const ResolveScreen: React.FC = ({ api, dictionary, userEmail: userEmail.trim() || undefined, projectId: projectId.trim() || undefined, status, - fieldValues: conditions.length > 0 ? conditions : undefined + fieldValues: conditions.length > 0 ? conditions : undefined, + departmentId: departmentId.trim() || undefined }); }; @@ -197,6 +208,19 @@ export const ResolveScreen: React.FC = ({ api, dictionary, onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })} /> + + ({ value: d.id, label: d.name, sublabel: d.company }))} + value={departmentId} + loading={dictionaries.isLoading} + allowClear + clearLabel={texts.resolve.departmentClear} + placeholder={texts.resolve.departmentPlaceholder} + searchPlaceholder={texts.resolve.departmentSearchPlaceholder} + emptyMessage={texts.resolve.departmentEmpty} + onChange={departmentId => setForm({ ...form, departmentId })} + /> +