From 14b25bf8cc56a744082be60c980944cd308428cf Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 16:22:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B5=D0=BE=D0=B1=D0=B7=D1=8F=D1=82=D0=B0=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TemplateRoutes/TemplateRouteDto.cs | 6 +-- .../TemplateRoutes/TemplateRouteService.cs | 48 ++++++++++++------- .../TemplateRoutes/TemplateRouteValidators.cs | 28 ++--------- .../Entities/TemplateRoute.cs | 2 +- ...SectionAndStepConditionFields.Designer.cs} | 4 +- ...eListNameSectionAndStepConditionFields.cs} | 21 +++++++- .../ApprovalRouteDbContextModelSnapshot.cs | 2 +- .../TemplateRouteServiceTests.cs | 42 ++++++++++++++++ .../components/screens/RouteFormModal.tsx | 15 +++--- .../components/screens/RoutesScreen.tsx | 2 +- .../approvalAdmin/models/templateRoutes.ts | 9 ++-- frontend/src/webparts/approvalAdmin/texts.ts | 6 +-- 12 files changed, 120 insertions(+), 65 deletions(-) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs => 20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs} (98%) rename backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/{20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs => 20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs} (81%) diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 82aee23..cd83e75 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -4,20 +4,20 @@ public sealed record TemplateRouteDto( int Id, int RequestTypeId, Guid? DepartmentId, - Guid CompanyId, + Guid? CompanyId, bool ByProject, bool RequiresControl); public sealed record CreateTemplateRouteRequest( int RequestTypeId, Guid? DepartmentId, - Guid CompanyId, + Guid? CompanyId, bool ByProject, bool RequiresControl); public sealed record UpdateTemplateRouteRequest( Guid? DepartmentId, - Guid CompanyId, + Guid? CompanyId, bool ByProject, bool RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index b98ed72..17766ad 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -140,22 +140,11 @@ public sealed class TemplateRouteService( var byProject = request.ProjectId is not null; - var route = await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) - .Where(x => - x.RequestTypeId == request.RequestTypeId && - x.CompanyId == employee.CompanyId && - x.ByProject == byProject && - x.DepartmentId == employee.DepartmentId) - .FirstOrDefaultAsync(cancellationToken) - ?? await db.TemplateRoutes.AsNoTracking() - .Include(x => x.Steps) - .Where(x => - x.RequestTypeId == request.RequestTypeId && - x.CompanyId == employee.CompanyId && - x.ByProject == byProject && - x.DepartmentId == null) - .FirstOrDefaultAsync(cancellationToken); + // Приоритет — маршрут с компанией инициатора; если для этой компании + // своего маршрута нет, берём запасной маршрут без указанной компании + // (тот же принцип, что уже применяется к отделу внутри FindRouteAsync). + var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken) + ?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken); if (route is null) return Result.Fail(new NotFoundError( @@ -186,8 +175,33 @@ public sealed class TemplateRouteService( return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); } + /// + /// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное + /// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела + /// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает + /// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null). + /// + private async Task FindRouteAsync( + int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) => + await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == requestTypeId && + x.CompanyId == companyId && + x.ByProject == byProject && + x.DepartmentId == departmentId) + .FirstOrDefaultAsync(cancellationToken) + ?? await db.TemplateRoutes.AsNoTracking() + .Include(x => x.Steps) + .Where(x => + x.RequestTypeId == requestTypeId && + x.CompanyId == companyId && + x.ByProject == byProject && + x.DepartmentId == null) + .FirstOrDefaultAsync(cancellationToken); + private Task HasDuplicateAsync( - int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) => + int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) => db.TemplateRoutes.AnyAsync(x => x.Id != excludeId && x.RequestTypeId == requestTypeId && diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 63b67cf..456b724 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -3,30 +3,10 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; -public sealed class CreateTemplateRouteRequestValidator : AbstractValidator -{ - public CreateTemplateRouteRequestValidator() - { - RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); - } -} - -public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator -{ - public UpdateTemplateRouteRequestValidator() - { - RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна."); - } -} - -public sealed class PatchTemplateRouteRequestValidator : AbstractValidator -{ - public PatchTemplateRouteRequestValidator() - { - When(x => x.CompanyId is not null, () => - RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна.")); - } -} +// Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/ +// UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных +// валидаторах: маршрут без компании служит запасным вариантом, когда для +// конкретной компании инициатора своего маршрута нет (см. ResolveAsync). public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator { diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index c5ac975..abe7b09 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -6,7 +6,7 @@ public class TemplateRoute public int RequestTypeId { get; set; } public Guid? DepartmentId { get; set; } - public Guid CompanyId { get; set; } + public Guid? CompanyId { get; set; } public bool ByProject { get; set; } public bool RequiresControl { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs similarity index 98% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs index 011788e..09c01ed 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs @@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence; namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations { [DbContext(typeof(ApprovalRouteDbContext))] - [Migration("20260731093159_AddRequestTypeListNameSectionAndStepConditionFields")] + [Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")] partial class AddRequestTypeListNameSectionAndStepConditionFields { /// @@ -81,7 +81,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("by_project"); - b.Property("CompanyId") + b.Property("CompanyId") .HasColumnType("uuid") .HasColumnName("company_id"); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs similarity index 81% rename from backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs rename to backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs index 29c2f4f..b434649 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731093159_AddRequestTypeListNameSectionAndStepConditionFields.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -14,6 +15,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations name: "route_type", table: "template_routes"); + migrationBuilder.AlterColumn( + name: "company_id", + table: "template_routes", + type: "uuid", + nullable: true, + oldClrType: typeof(Guid), + oldType: "uuid"); + migrationBuilder.AddColumn( name: "field_name", table: "template_route_steps", @@ -85,6 +94,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations name: "site_url", table: "request_types"); + migrationBuilder.AlterColumn( + name: "company_id", + table: "template_routes", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "uuid", + oldNullable: true); + migrationBuilder.AddColumn( name: "route_type", table: "template_routes", diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 0d54ade..09ed308 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -78,7 +78,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("by_project"); - b.Property("CompanyId") + b.Property("CompanyId") .HasColumnType("uuid") .HasColumnName("company_id"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 42b3c62..a8eb088 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -583,6 +583,48 @@ public sealed class TemplateRouteServiceTests : IClassFixture = ({ submit.fail(texts.routes.validationRequestType); return; } - if (!form.companyId.trim()) { - submit.fail(texts.routes.validationCompany); - return; - } + const companyId = form.companyId.trim() ? form.companyId.trim() : null; const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null; const request = editing ? api.templateRoutes.update(editing.id, { - companyId: form.companyId.trim(), + companyId, departmentId, byProject: form.byProject, requiresControl: form.requiresControl }) : api.templateRoutes.create({ requestTypeId: Number(form.requestTypeId), - companyId: form.companyId.trim(), + companyId, departmentId, byProject: form.byProject, requiresControl: form.requiresControl @@ -120,11 +117,13 @@ export const RouteFormModal: React.FC = ({ onChange={e => setForm({ ...form, requestTypeId: e.target.value })} /> - + ({ value: c.id, label: c.name }))} value={form.companyId} loading={dictionaryLoading} + allowClear + clearLabel={texts.routes.companyClear} placeholder={texts.routes.companyPlaceholder} searchPlaceholder={texts.routes.companySearchPlaceholder} onChange={companyId => diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx index f09d80b..686bd5f 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesScreen.tsx @@ -92,7 +92,7 @@ export const RoutesScreen: React.FC = ({ api, dictionary, on header: texts.routes.columnRequestType, render: r => {requestTypeName(r.requestTypeId)} }, - { key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) }, + { key: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) }, { key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) }, { key: 'byProject', diff --git a/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts b/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts index 2b7d779..5447dcc 100644 --- a/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts +++ b/frontend/src/webparts/approvalAdmin/models/templateRoutes.ts @@ -4,7 +4,8 @@ export interface TemplateRoute { id: number; requestTypeId: number; departmentId: string | null; - companyId: string; + /** Опционально — маршрут без компании служит запасным вариантом при resolve. */ + companyId: string | null; byProject: boolean; requiresControl: boolean; } @@ -12,7 +13,7 @@ export interface TemplateRoute { export interface CreateTemplateRouteInput { requestTypeId: number; departmentId: string | null; - companyId: string; + companyId: string | null; byProject: boolean; requiresControl: boolean; } @@ -20,14 +21,14 @@ export interface CreateTemplateRouteInput { // Тип заявки у существующего маршрута не меняется — его нет в update-контракте. export interface UpdateTemplateRouteInput { departmentId: string | null; - companyId: string; + companyId: string | null; byProject: boolean; requiresControl: boolean; } export interface PatchTemplateRouteInput { departmentId?: string | null; - companyId?: string; + companyId?: string | null; byProject?: boolean; requiresControl?: boolean; } diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index ec90b23..9944df1 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -152,9 +152,10 @@ export const texts = { requestTypeLabel: 'Тип заявки', requestTypePlaceholder: 'Выберите тип заявки', companyLabel: 'Компания', - companyHint: 'Справочник 1С (api.tnsu.kz).', - companyPlaceholder: 'Выберите компанию', + companyHint: 'Необязательно — пусто означает запасной маршрут, если для компании инициатора своего маршрута нет.', + companyPlaceholder: 'Без компании (запасной маршрут)', companySearchPlaceholder: 'Поиск по названию компании...', + companyClear: 'Без компании (запасной маршрут)', departmentLabel: 'Отдел', departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.', departmentHintNoCompany: 'Сначала выберите компанию.', @@ -166,7 +167,6 @@ export const texts = { controlLabel: 'Контроль', controlCheckbox: 'Требуется отдельный контроль исполнения', validationRequestType: 'Выберите тип заявки.', - validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).', deleteTitle: 'Удалить маршрут?', deleteText: (routeId: number): string => `Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`