From 13db1388a32068331b81b5e132e6391299265f65 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 31 Jul 2026 12:15:15 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D1=8B=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/6 --- .../{deploy.yml => deploy_backend.yml} | 0 .gitea/workflows/deploy_frontend.yml | 98 ++++++++ .../Controllers/TemplateRoutesController.cs | 7 +- .../Tnsu.ApprovalRoute.Api/appsettings.json | 3 +- .../RequestTypes/RequestTypeDto.cs | 8 +- .../RequestTypes/RequestTypeService.cs | 34 ++- .../RequestTypes/RequestTypeValidators.cs | 16 ++ .../TemplateRouteStepDto.cs | 18 +- .../TemplateRouteStepService.cs | 53 +++- .../TemplateRouteStepValidators.cs | 17 ++ .../TemplateRoutes/ResolveTemplateRouteDto.cs | 7 +- .../TemplateRoutes/TemplateRouteDto.cs | 12 +- .../TemplateRoutes/TemplateRouteService.cs | 95 ++++++-- .../TemplateRoutes/TemplateRouteValidators.cs | 33 +-- .../Entities/RequestType.cs | 3 + .../Entities/TemplateRoute.cs | 5 +- .../Entities/TemplateRouteStep.cs | 4 + .../Enums/ComparisonOperator.cs | 9 + .../Enums/RouteType.cs | 8 - .../Persistence/ApprovalRouteDbContext.cs | 7 +- ...eSectionAndStepConditionFields.Designer.cs | 217 +++++++++++++++++ ...peListNameSectionAndStepConditionFields.cs | 116 +++++++++ .../ApprovalRouteDbContextModelSnapshot.cs | 39 ++- .../RequestTypeServiceTests.cs | 44 +++- .../TemplateRouteServiceTests.cs | 227 +++++++++++++++--- .../TemplateRouteStepServiceTests.cs | 117 +++++++-- frontend/package-lock.json | 71 ++++++ frontend/package.json | 1 + frontend/src/styles/design-tokens.scss | 15 +- .../approvalAdmin/assets/welcome-dark.png | Bin 12545 -> 0 bytes .../approvalAdmin/assets/welcome-light.png | Bin 12816 -> 0 bytes .../components/ApprovalAdmin.tsx | 3 + .../approvalAdmin/components/app/AdminApp.tsx | 20 +- .../components/app/TopNav.module.scss | 14 -- .../approvalAdmin/components/app/TopNav.tsx | 1 - .../components/screens/RequestTypesScreen.tsx | 102 +++++++- .../screens/ResolveScreen.module.scss | 34 +++ .../components/screens/ResolveScreen.tsx | 132 ++++++++-- .../components/screens/RouteFormModal.tsx | 35 +-- .../components/screens/RouteStepFormModal.tsx | 122 +++++++++- .../components/screens/RouteStepsScreen.tsx | 25 +- .../components/screens/RoutesScreen.tsx | 11 +- .../components/ui/AsyncSearchableSelect.tsx | 103 ++++---- .../components/ui/DropdownPortal.tsx | 78 ++++++ .../components/ui/PageChrome.module.scss | 23 +- .../ui/SearchableSelect.module.scss | 32 ++- .../components/ui/SearchableSelect.tsx | 73 +++--- .../hooks/useLocalStorageState.ts | 39 +++ .../hooks/useSharePointFields.ts | 41 ++++ .../approvalAdmin/hooks/useSharePointLists.ts | 45 ++++ .../hooks/useSharePointSiteTitle.ts | 36 +++ .../webparts/approvalAdmin/models/enums.ts | 13 +- .../approvalAdmin/models/requestTypes.ts | 11 + .../webparts/approvalAdmin/models/resolve.ts | 10 +- .../models/templateRouteSteps.ts | 13 +- .../approvalAdmin/models/templateRoutes.ts | 15 +- .../webparts/approvalAdmin/services/api.ts | 4 +- .../approvalAdmin/services/sharePointLists.ts | 150 ++++++++++++ frontend/src/webparts/approvalAdmin/texts.ts | 78 ++++-- .../approvalAdmin/utils/stepExecutor.ts | 10 +- 60 files changed, 2151 insertions(+), 406 deletions(-) rename .gitea/workflows/{deploy.yml => deploy_backend.yml} (100%) create mode 100644 .gitea/workflows/deploy_frontend.yml create mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs delete mode 100644 backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs create mode 100644 backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs delete mode 100644 frontend/src/webparts/approvalAdmin/assets/welcome-dark.png delete mode 100644 frontend/src/webparts/approvalAdmin/assets/welcome-light.png create mode 100644 frontend/src/webparts/approvalAdmin/components/ui/DropdownPortal.tsx create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useLocalStorageState.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointFields.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointLists.ts create mode 100644 frontend/src/webparts/approvalAdmin/hooks/useSharePointSiteTitle.ts create mode 100644 frontend/src/webparts/approvalAdmin/services/sharePointLists.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy_backend.yml similarity index 100% rename from .gitea/workflows/deploy.yml rename to .gitea/workflows/deploy_backend.yml diff --git a/.gitea/workflows/deploy_frontend.yml b/.gitea/workflows/deploy_frontend.yml new file mode 100644 index 0000000..9aae415 --- /dev/null +++ b/.gitea/workflows/deploy_frontend.yml @@ -0,0 +1,98 @@ +name: Build & Deploy to SharePoint Online + +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v3 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: | + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 10000 + npm config set fetch-retry-maxtimeout 120000 + for i in 1 2 3; do + npm install --legacy-peer-deps && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + - name: Build & package SPFx solution + run: npm run build + + - name: Upload .sppkg artifact + uses: actions/upload-artifact@v3 + with: + name: approval-route-admin-sppkg + path: frontend/sharepoint/solution/approval-route-admin.sppkg + retention-days: 7 + + - name: Install CLI for Microsoft 365 + run: | + for i in 1 2 3; do + npm install -g @pnp/cli-microsoft365@9 && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + + - name: Login to Microsoft 365 (certificate) + run: | + m365 login \ + --authType certificate \ + --appId "${{ secrets.SPO_CLIENT_ID }}" \ + --certificateBase64Encoded "${{ secrets.SPO_CERT_BASE64 }}" \ + --thumbprint "${{ secrets.SPO_CERT_THUMBPRINT }}" \ + --tenant "${{ secrets.SPO_TENANT_ID }}" + + - name: Upload & Deploy to App Catalog + env: + SOLUTION_ID: "ce823ddf-e35c-460f-a92d-ddb4ef5fc494" + CATALOG_SITE: "https://tnsukz.sharepoint.com/sites/appcatalog" + SPPKG: "sharepoint/solution/approval-route-admin.sppkg" + FILE_NAME: "approval-route-admin.sppkg" + run: | + # Получаем токен через уже аутентифицированный m365 CLI + TOKEN=$(m365 util accesstoken get --resource https://tnsukz.sharepoint.com --output text) + echo "Token length: ${#TOKEN}" + + echo ">>> Uploading to App Catalog" + HTTP=$(curl -s -o /tmp/upload.json -w "%{http_code}" -X POST \ + "${CATALOG_SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/appcatalog/AppCatalog')/Files/add(overwrite=true,url='${FILE_NAME}')" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-RequestDigest: 0x0" \ + -H "Accept: application/json;odata=nometadata" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${SPPKG}" || echo "000") + echo "Upload HTTP: ${HTTP}" + cat /tmp/upload.json + [ "$HTTP" = "200" ] || [ "$HTTP" = "201" ] || exit 1 + + echo ">>> Deploying app (id: ${SOLUTION_ID})" + HTTP=$(curl -s -o /tmp/deploy.json -w "%{http_code}" -X POST \ + "${CATALOG_SITE}/_api/web/tenantappcatalog/AvailableApps/GetById('${SOLUTION_ID}')/deploy" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-RequestDigest: 0x0" \ + -H "Accept: application/json;odata=nometadata" \ + -H "Content-Type: application/json" \ + -d '{"skipFeatureDeployment":true}' || echo "000") + echo "Deploy HTTP: ${HTTP}" + cat /tmp/deploy.json + [ "$HTTP" = "200" ] || [ "$HTTP" = "204" ] || exit 1 + + echo ">>> Done" diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 8b38e22..e3f7127 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -11,11 +11,14 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [Produces("application/json")] public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase { - [HttpGet("resolve")] + // POST, а не GET: запрос принимает список условий (field_name/value) переменной длины, + // который не ложится на плоскую query-строку без индексной эквилибристики. Единственный + // потребитель — диагностический экран «Проверка маршрута» в админке, внешних вызовов нет. + [HttpPost("resolve")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] - public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) => + public async Task Resolve([FromBody] ResolveTemplateRouteRequest request, CancellationToken ct) => (await service.ResolveAsync(request, ct)).ToActionResult(); [HttpGet] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json index 12fe82b..eac5c14 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/src/Tnsu.ApprovalRoute.Api/appsettings.json @@ -6,9 +6,10 @@ } }, "ConnectionStrings": { - "Postgres": "Host=db-master.tnsu.kz;Database=tnsu_approval_route;Username=dbuser;Password=p}{6{Pa]{JQwP1H" + "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" }, "Entra": { + "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "Audience": "9232d9e2-9807-4753-808f-07250e5474b0" }, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index 50cdce7..59b6536 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -1,9 +1,9 @@ namespace Tnsu.ApprovalRoute.Application.RequestTypes; -public sealed record RequestTypeDto(int Id, string Name, string PageUrl); +public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record CreateRequestTypeRequest(string Name, string PageUrl); +public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); +public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListName, string? SiteUrl, string Section); -public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl); +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListName, string? SiteUrl, string? Section); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index 481c5d9..83aface 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) { var items = await db.RequestTypes.AsNoTracking() .OrderBy(x => x.Name) - .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl)) + .Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListName, x.SiteUrl, x.Section)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -29,7 +29,18 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) public async Task> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) { - var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl }; + if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + + var entity = new RequestType + { + Name = request.Name, + PageUrl = request.PageUrl, + ListName = request.ListName, + SiteUrl = request.SiteUrl, + Section = request.Section + }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(cancellationToken); @@ -42,8 +53,15 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); + if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + entity.Name = request.Name; entity.PageUrl = request.PageUrl; + entity.ListName = request.ListName; + entity.SiteUrl = request.SiteUrl; + entity.Section = request.Section; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); @@ -55,8 +73,18 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) if (entity is null) return Result.Fail(new NotFoundError("Тип заявки", id)); + var listName = request.ListName ?? entity.ListName; + var siteUrl = request.SiteUrl ?? entity.SiteUrl; + + if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl)) + return Result.Fail(new ValidationError( + "Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.")); + entity.Name = request.Name ?? entity.Name; entity.PageUrl = request.PageUrl ?? entity.PageUrl; + entity.ListName = listName; + entity.SiteUrl = siteUrl; + entity.Section = request.Section ?? entity.Section; await db.SaveChangesAsync(cancellationToken); return Result.Ok(ToDto(entity)); @@ -74,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(); } - private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl); + private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListName, e.SiteUrl, e.Section); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index 51946db..33852e9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -8,6 +8,10 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); + RuleFor(x => x) + .Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl)) + .WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); } } @@ -17,6 +21,10 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); + RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел обязателен."); + RuleFor(x => x) + .Must(x => RequestTypeFields.IsListReferenceValid(x.ListName, x.SiteUrl)) + .WithMessage("Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."); } } @@ -28,5 +36,13 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым.")); When(x => x.PageUrl is not null, () => RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); + When(x => x.Section is not null, () => + RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым.")); } } + +internal static class RequestTypeFields +{ + public static bool IsListReferenceValid(string? listName, string? siteUrl) => + string.IsNullOrWhiteSpace(listName) == string.IsNullOrWhiteSpace(siteUrl); +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index ca3074e..200c375 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -12,6 +12,9 @@ public sealed record TemplateRouteStepDto( RouteStepStatus Status, int RouteId, bool AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value, string? Warning = null); public sealed record CreateTemplateRouteStepRequest( @@ -21,7 +24,10 @@ public sealed record CreateTemplateRouteStepRequest( ProjectRole? ProjectRole, HeadType? HeadType, RouteStepStatus Status, - bool AllowReassignToOtherApprovers); + bool AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value); public sealed record UpdateTemplateRouteStepRequest( int OrderNo, @@ -30,7 +36,10 @@ public sealed record UpdateTemplateRouteStepRequest( ProjectRole? ProjectRole, HeadType? HeadType, RouteStepStatus Status, - bool AllowReassignToOtherApprovers); + bool AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value); public sealed record PatchTemplateRouteStepRequest( int? OrderNo, @@ -39,4 +48,7 @@ public sealed record PatchTemplateRouteStepRequest( ProjectRole? ProjectRole, HeadType? HeadType, RouteStepStatus? Status, - bool? AllowReassignToOtherApprovers); + bool? AllowReassignToOtherApprovers, + string? FieldName, + ComparisonOperator? Operation, + string? Value); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 9e100bb..4525edf 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -15,7 +15,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) .Where(x => x.RouteId == routeId) .OrderBy(x => x.OrderNo) .Select(x => new TemplateRouteStepDto( - x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers)) + x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers, + x.FieldName, x.Operation, x.Value)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -33,7 +34,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public async Task> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); @@ -41,6 +42,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + + if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name).")); + var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken); if (duplicate) @@ -56,7 +65,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) ProjectRole = r.ProjectRole, HeadType = r.HeadType, Status = r.Status, - AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers + AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers, + FieldName = r.FieldName, + Operation = r.Operation, + Value = r.Value }; db.TemplateRouteSteps.Add(entity); await db.SaveChangesAsync(cancellationToken); @@ -67,7 +79,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public async Task> UpdateAsync( int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); @@ -79,6 +91,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ValidationError( "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + + if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name).")); + var duplicate = await HasDuplicateAsync( route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken); if (duplicate) @@ -92,6 +112,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.HeadType = r.HeadType; entity.Status = r.Status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; + entity.FieldName = r.FieldName; + entity.Operation = r.Operation; + entity.Value = r.Value; await db.SaveChangesAsync(cancellationToken); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); @@ -100,7 +123,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) public async Task> PatchAsync( int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken) { - var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); + var route = await db.TemplateRoutes.Include(x => x.RequestType).FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken); if (route is null) return Result.Fail(new NotFoundError("Маршрут", routeId)); @@ -113,6 +136,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) var projectRole = r.ProjectRole ?? entity.ProjectRole; var headType = r.HeadType ?? entity.HeadType; var status = r.Status ?? entity.Status; + var fieldName = r.FieldName ?? entity.FieldName; + var operation = r.Operation ?? entity.Operation; + var value = r.Value ?? entity.Value; if (status == RouteStepStatus.None) return Result.Fail(new ValidationError("Статус обязателен: Approval или Execution.")); @@ -125,6 +151,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Fail(new ValidationError( "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); + if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.")); + + if (RequiresListName(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName)) + return Result.Fail(new ValidationError( + "Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name).")); + var duplicate = await HasDuplicateAsync( route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); if (duplicate) @@ -138,6 +172,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) entity.HeadType = headType; entity.Status = status; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; + entity.FieldName = fieldName; + entity.Operation = operation; + entity.Value = value; await db.SaveChangesAsync(cancellationToken); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); @@ -155,6 +192,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(); } + private static bool RequiresListName(string? fieldName, ComparisonOperator? operation, string? value) => + TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0; + private Task HasDuplicateAsync( int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) => @@ -184,5 +224,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) } private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( - e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers); + e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers, + e.FieldName, e.Operation, e.Value); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs index 89357a5..1c4ac4b 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs @@ -11,6 +11,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator< RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) + .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); } } @@ -22,6 +25,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator< RuleFor(x => x) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); + RuleFor(x => x) + .Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value)) + .WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."); } } @@ -32,4 +38,15 @@ internal static class TemplateRouteStepFields (string.IsNullOrWhiteSpace(position) ? 0 : 1) + (projectRole is null or ProjectRole.None ? 0 : 1) + (headType is null or HeadType.None ? 0 : 1); + + public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) => + (string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) + + (operation is null or ComparisonOperator.None ? 0 : 1) + + (string.IsNullOrWhiteSpace(value) ? 0 : 1); + + public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value) + { + var count = CountConditionFilled(fieldName, operation, value); + return count == 0 || count == 3; + } } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs index bf5b484..8492c1d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/ResolveTemplateRouteDto.cs @@ -12,12 +12,15 @@ public sealed record ResolvedRouteStepDto( public sealed record ResolveTemplateRouteResultDto( int RouteId, - RouteType RouteType, bool RequiresControl, IReadOnlyList Steps); +/// Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve. +public sealed record ResolveFieldValueDto(string FieldName, string Value); + public sealed record ResolveTemplateRouteRequest( int RequestTypeId, string? UserEmail, Guid? ProjectId, - RouteStepStatus Status); + RouteStepStatus Status, + IReadOnlyList? FieldValues = null); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 6992701..cd83e75 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -1,33 +1,27 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; public sealed record TemplateRouteDto( int Id, - RouteType RouteType, int RequestTypeId, Guid? DepartmentId, - Guid CompanyId, + Guid? CompanyId, bool ByProject, bool RequiresControl); public sealed record CreateTemplateRouteRequest( - RouteType RouteType, int RequestTypeId, Guid? DepartmentId, - Guid CompanyId, + Guid? CompanyId, bool ByProject, bool RequiresControl); public sealed record UpdateTemplateRouteRequest( - RouteType RouteType, Guid? DepartmentId, - Guid CompanyId, + Guid? CompanyId, bool ByProject, bool RequiresControl); public sealed record PatchTemplateRouteRequest( - RouteType? RouteType, Guid? DepartmentId, Guid? CompanyId, bool? ByProject, diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index a513f06..17766ad 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using FluentResults; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -24,7 +25,7 @@ public sealed class TemplateRouteService( var items = await query .Select(x => new TemplateRouteDto( - x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) + x.Id, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl)) .ToListAsync(cancellationToken); return Result.Ok>(items); @@ -52,7 +53,6 @@ public sealed class TemplateRouteService( var entity = new TemplateRoute { - RouteType = r.RouteType, RequestTypeId = r.RequestTypeId, DepartmentId = r.DepartmentId, CompanyId = r.CompanyId, @@ -76,7 +76,6 @@ public sealed class TemplateRouteService( return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); - entity.RouteType = r.RouteType; entity.DepartmentId = r.DepartmentId; entity.CompanyId = r.CompanyId; entity.ByProject = r.ByProject; @@ -101,7 +100,6 @@ public sealed class TemplateRouteService( return Result.Fail(new ConflictError( "Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует.")); - entity.RouteType = r.RouteType ?? entity.RouteType; entity.DepartmentId = departmentId; entity.CompanyId = companyId; entity.ByProject = byProject; @@ -142,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( @@ -168,8 +155,14 @@ public sealed class TemplateRouteService( if (request.ProjectId is { } projectId) projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); + var fieldValues = (request.FieldValues ?? []) + .Where(f => !string.IsNullOrWhiteSpace(f.FieldName)) + .GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase); + var matchingSteps = route.Steps .Where(s => s.Status == request.Status) + .Where(s => MatchesCondition(s, fieldValues)) .OrderBy(s => s.OrderNo) .ToList(); @@ -179,11 +172,36 @@ public sealed class TemplateRouteService( var collapsedSteps = CollapseByExecutorEmail(steps); - return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps)); + 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 && @@ -243,6 +261,37 @@ public sealed class TemplateRouteService( return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning); } + /// + /// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе. + /// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля + /// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный + /// сценарий, и отсутствие значения означает, что условие не выполняется. + /// + private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary fieldValues) + { + if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None) + return true; + + if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual)) + return false; + + var expected = step.Value; + return step.Operation switch + { + ComparisonOperator.Equal => + string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) || + (TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected), + ComparisonOperator.GreaterThan => + TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected, + ComparisonOperator.LessThan => + TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected, + _ => true + }; + } + + private static bool TryParseNumber(string? value, out double number) => + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); + private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) { var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -260,5 +309,5 @@ public sealed class TemplateRouteService( } private static TemplateRouteDto ToDto(TemplateRoute e) => new( - e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); + e.Id, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl); } diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 23019e4..456b724 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -3,35 +3,20 @@ 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 { public ResolveTemplateRouteRequestValidator() { RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); + RuleForEach(x => x.FieldValues).ChildRules(v => + { + v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно."); + v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно."); + }); } } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs index d86af83..5b9f8bf 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/RequestType.cs @@ -5,6 +5,9 @@ public class RequestType public int Id { get; set; } public string Name { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty; + public string? ListName { get; set; } + public string? SiteUrl { get; set; } + public string Section { get; set; } = string.Empty; public ICollection Routes { get; set; } = new List(); } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index 9319917..abe7b09 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -1,15 +1,12 @@ -using Tnsu.ApprovalRoute.Domain.Enums; - namespace Tnsu.ApprovalRoute.Domain.Entities; public class TemplateRoute { public int Id { get; set; } - public RouteType RouteType { get; set; } = RouteType.Sequential; 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.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 9901b07..ce5a6be 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -16,5 +16,9 @@ public class TemplateRouteStep public int RouteId { get; set; } public bool AllowReassignToOtherApprovers { get; set; } + public string? FieldName { get; set; } + public ComparisonOperator? Operation { get; set; } + public string? Value { get; set; } + public TemplateRoute? Route { get; set; } } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs new file mode 100644 index 0000000..7bfbdce --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ComparisonOperator.cs @@ -0,0 +1,9 @@ +namespace Tnsu.ApprovalRoute.Domain.Enums; + +public enum ComparisonOperator +{ + None, + GreaterThan, + LessThan, + Equal +} diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs deleted file mode 100644 index 8004453..0000000 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/RouteType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Tnsu.ApprovalRoute.Domain.Enums; - -public enum RouteType -{ - None, - Parallel, - Sequential -} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs index 2c84977..e9ba224 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/ApprovalRouteDbContext.cs @@ -16,12 +16,14 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.HasKey(x => x.Id); e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); + e.Property(x => x.ListName).HasMaxLength(256); + e.Property(x => x.SiteUrl).HasMaxLength(512); + e.Property(x => x.Section).HasMaxLength(256).IsRequired(); }); modelBuilder.Entity(e => { e.HasKey(x => x.Id); - e.Property(x => x.RouteType).HasConversion().HasMaxLength(32).IsRequired(); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique(); }); @@ -34,6 +36,9 @@ public class ApprovalRouteDbContext(DbContextOptions opt e.Property(x => x.ProjectRole).HasConversion().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion().HasMaxLength(32); e.Property(x => x.Status).HasConversion().HasMaxLength(32).IsRequired(); + e.Property(x => x.FieldName).HasMaxLength(128); + e.Property(x => x.Operation).HasConversion().HasMaxLength(32); + e.Property(x => x.Value).HasMaxLength(256); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasIndex(x => new { x.RouteId, x.OrderNo }); }); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs new file mode 100644 index 0000000..09c01ed --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.Designer.cs @@ -0,0 +1,217 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApprovalRouteDbContext))] + [Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")] + partial class AddRequestTypeListNameSectionAndStepConditionFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ListName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("list_name"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("page_url"); + + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + + b.HasKey("Id") + .HasName("pk_request_types"); + + b.ToTable("request_types", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByProject") + .HasColumnType("boolean") + .HasColumnName("by_project"); + + b.Property("CompanyId") + .HasColumnType("uuid") + .HasColumnName("company_id"); + + b.Property("DepartmentId") + .HasColumnType("uuid") + .HasColumnName("department_id"); + + b.Property("RequestTypeId") + .HasColumnType("integer") + .HasColumnName("request_type_id"); + + b.Property("RequiresControl") + .HasColumnType("boolean") + .HasColumnName("requires_control"); + + b.HasKey("Id") + .HasName("pk_template_routes"); + + b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject") + .IsUnique() + .HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by"); + + b.ToTable("template_routes", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowReassignToOtherApprovers") + .HasColumnType("boolean") + .HasColumnName("allow_reassign_to_other_approvers"); + + b.Property("ExecutorEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("executor_email"); + + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + + b.Property("HeadType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("head_type"); + + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + + b.Property("OrderNo") + .HasColumnType("integer") + .HasColumnName("order_no"); + + b.Property("Position") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("position"); + + b.Property("ProjectRole") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("project_role"); + + b.Property("RouteId") + .HasColumnType("integer") + .HasColumnName("route_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_template_route_steps"); + + b.HasIndex("RouteId", "OrderNo") + .HasDatabaseName("ix_template_route_steps_route_id_order_no"); + + b.ToTable("template_route_steps", (string)null); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") + .WithMany("Routes") + .HasForeignKey("RequestTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_routes_request_types_request_type_id"); + + b.Navigation("RequestType"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b => + { + b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route") + .WithMany("Steps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_template_route_steps_template_routes_route_id"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs new file mode 100644 index 0000000..b434649 --- /dev/null +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/20260731111928_AddRequestTypeListNameSectionAndStepConditionFields.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRequestTypeListNameSectionAndStepConditionFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + 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", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "operation", + table: "template_route_steps", + type: "character varying(32)", + maxLength: 32, + nullable: true); + + migrationBuilder.AddColumn( + name: "value", + table: "template_route_steps", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "list_name", + table: "request_types", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "section", + table: "request_types", + type: "character varying(256)", + maxLength: 256, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "site_url", + table: "request_types", + type: "character varying(512)", + maxLength: 512, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "field_name", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "operation", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "value", + table: "template_route_steps"); + + migrationBuilder.DropColumn( + name: "list_name", + table: "request_types"); + + migrationBuilder.DropColumn( + name: "section", + table: "request_types"); + + migrationBuilder.DropColumn( + 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", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs index 5b2cea2..09ed308 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/Persistence/Migrations/ApprovalRouteDbContextModelSnapshot.cs @@ -31,6 +31,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("ListName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("list_name"); + b.Property("Name") .IsRequired() .HasMaxLength(256) @@ -43,6 +48,17 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(512)") .HasColumnName("page_url"); + b.Property("Section") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("section"); + + b.Property("SiteUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("site_url"); + b.HasKey("Id") .HasName("pk_request_types"); @@ -62,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"); @@ -78,12 +94,6 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("boolean") .HasColumnName("requires_control"); - b.Property("RouteType") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasColumnName("route_type"); - b.HasKey("Id") .HasName("pk_template_routes"); @@ -112,11 +122,21 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(256)") .HasColumnName("executor_email"); + b.Property("FieldName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("field_name"); + b.Property("HeadType") .HasMaxLength(32) .HasColumnType("character varying(32)") .HasColumnName("head_type"); + b.Property("Operation") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("operation"); + b.Property("OrderNo") .HasColumnType("integer") .HasColumnName("order_no"); @@ -141,6 +161,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("status"); + b.Property("Value") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("value"); + b.HasKey("Id") .HasName("pk_template_route_steps"); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index 30ff46f..d861696 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() { // Action - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation", null, null, "General"), CancellationToken.None); // Assert created.IsSuccess.ShouldBeTrue(); @@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task UpdateAsync_ChangesFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old", null, null, "General"), CancellationToken.None); // Action var updated = await _service.UpdateAsync( - created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None); + created.Value.Id, new UpdateRequestTypeRequest("New name", "/new", null, null, "General"), CancellationToken.None); // Assert updated.IsSuccess.ShouldBeTrue(); @@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.UpdateAsync( - 999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); + 999999, new UpdateRequestTypeRequest("X", "/x", null, null, "General"), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task PatchAsync_UpdatesOnlyProvidedFields() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original", null, null, "General"), CancellationToken.None); // Action var patched = await _service.PatchAsync( - created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None); + created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None); // Assert patched.IsSuccess.ShouldBeTrue(); @@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I { // Action var result = await _service.PatchAsync( - 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None); + 999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null, ListName: null, SiteUrl: null, Section: null), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); @@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I public async Task DeleteAsync_RemovesEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp", null, null, "General"), CancellationToken.None); // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); @@ -109,11 +109,37 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I fetched.IsFailed.ShouldBeTrue(); } + [Fact(DisplayName = "Создание типа заявки с list_name без site_url возвращает неуспешный результат")] + public async Task CreateAsync_ListNameWithoutSiteUrl_ReturnsFailedResult() + { + // Action + var result = await _service.CreateAsync( + new CreateRequestTypeRequest("Missing site url", "/missing-site-url", "Requests", null, "General"), CancellationToken.None); + + // Assert + result.IsFailed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Создание типа заявки с list_name и site_url успешно")] + public async Task CreateAsync_ListNameWithSiteUrl_ReturnsCreatedEntity() + { + // Action + var created = await _service.CreateAsync( + new CreateRequestTypeRequest( + "With list reference", "/with-list-reference", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"), + CancellationToken.None); + + // Assert + created.IsSuccess.ShouldBeTrue(); + created.Value.ListName.ShouldBe("Requests"); + created.Value.SiteUrl.ShouldBe("https://tnsukz.sharepoint.com/sites/hrm"); + } + [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")] public async Task ListAsync_IncludesCreatedEntity() { // Arrange - var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None); + var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed", null, null, "General"), CancellationToken.None); // Action var list = await _service.ListAsync(CancellationToken.None); diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 7bf9c42..a8eb088 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -38,7 +38,12 @@ public sealed class TemplateRouteServiceTests : IClassFixture CreateRequestTypeAsync(string name) => - (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; + (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id; + + private async Task CreateRequestTypeWithListAsync(string name) => + (await _requestTypeService.CreateAsync( + new CreateRequestTypeRequest(name, $"/{name}", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"), + CancellationToken.None)).Value.Id; [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() @@ -48,7 +53,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -196,22 +199,22 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -228,19 +231,129 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Amount", "1500")]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("big-amount@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв маршрута исключает шаг с условием, когда значение поля его не удовлетворяет")] + public async Task ResolveAsync_ConditionGreaterThan_NotSatisfiedByFieldValue_ExcludesStep() + { + // Arrange + var requestTypeId = await CreateRequestTypeWithListAsync("Condition request not satisfied"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + CancellationToken.None); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Amount", "500")]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("always@tnsu.kz"); + } + + [Fact(DisplayName = "Резолв маршрута исключает шаг с условием, если значение поля вообще не передано")] + public async Task ResolveAsync_ConditionField_MissingFromRequest_ExcludesStep() + { + // Arrange + var requestTypeId = await CreateRequestTypeWithListAsync("Condition request missing value"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"), + CancellationToken.None); + + // Action — fieldValues не переданы вовсе + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldBeEmpty(); + } + + [Fact(DisplayName = "Резолв маршрута с условием «равно» сравнивает значения как строки без учёта регистра")] + public async Task ResolveAsync_ConditionEqual_MatchesCaseInsensitive() + { + // Arrange + var requestTypeId = await CreateRequestTypeWithListAsync("Condition request equal"); + var route = await _service.CreateAsync( + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + CancellationToken.None); + + var stepService = _services.GetRequiredService(); + await stepService.CreateAsync( + route.Value.Id, + new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"), + CancellationToken.None); + + // Action + var resolved = await _service.ResolveAsync( + new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, + [new ResolveFieldValueDto("Priority", "URGENT")]), + CancellationToken.None); + + // Assert + resolved.IsSuccess.ShouldBeTrue(); + resolved.Value.Steps.ShouldHaveSingleItem(); + resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz"); + } + [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Project request"); var route = await _service.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false), + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -272,13 +385,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Position request", "/position-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -311,13 +424,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("Chief request", "/chief-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -346,13 +459,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); var requestTypeId = (await requestTypeService.CreateAsync( - new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), + new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -373,13 +486,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -401,13 +514,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -429,13 +542,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); await stepService.CreateAsync( route.Value.Id, - new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false), + new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, null, null), CancellationToken.None); // Action @@ -457,7 +570,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture(); } - private async Task CreateRouteAsync(string requestTypeName, bool byProject) + private async Task CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null) { + var siteUrl = listName is null ? null : "https://example.sharepoint.com/sites/test"; var requestTypeId = (await _requestTypeService.CreateAsync( - new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id; + new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, siteUrl, "General"), CancellationToken.None)).Value.Id; var route = await _routeService.CreateAsync( - new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), + new CreateTemplateRouteRequest(requestTypeId, null, CompanyId, byProject, RequiresControl: false), CancellationToken.None); return route.Value.Id; @@ -42,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@pnp/queryable": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/queryable/-/queryable-4.20.0.tgz", + "integrity": "sha512-JfnNrwPUYMP76gBx6rlVM4BGfsA+bU8bVYTY+RyHRPT4S0SIX2w6witvWHKTC0Ara9/JGGnbCI3LyQJ4457nGg==", + "license": "MIT", + "dependencies": { + "@pnp/core": "4.20.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/queryable/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@pnp/sp": { + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/@pnp/sp/-/sp-4.20.0.tgz", + "integrity": "sha512-vz+45igqipK9ZJj/jzRMpK6OqI2Szi1Z48J9QtLCBeZVxT0IwKMT/HMkZca03cQL7IDRPSjq27DYcXQRYeRw3w==", + "license": "MIT", + "dependencies": { + "@pnp/core": "4.20.0", + "@pnp/queryable": "4.20.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/patrick-rodgers/" + } + }, + "node_modules/@pnp/sp/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@rushstack/debug-certificate-manager": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7e04f47..dfde9d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@fluentui/react": "^8.106.4", + "@pnp/sp": "^4.20.0", "@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-http": "1.22.1", diff --git a/frontend/src/styles/design-tokens.scss b/frontend/src/styles/design-tokens.scss index 90c6b76..d7e2edc 100644 --- a/frontend/src/styles/design-tokens.scss +++ b/frontend/src/styles/design-tokens.scss @@ -106,19 +106,18 @@ $sp-7: 28px; $sp-8: 32px; } // ---- Готовые миксины ---- +// Светлая карточка заголовка страницы — сознательно НЕ грд-header (тёмный, +// как TopNav): два одинаковых тёмно-синих блока подряд читаются как один +// тяжёлый слипшийся элемент. Золотая полоса сверху — единственный акцент, +// вместо заливки всей карточки. @mixin page-header { - background: $grad-header; + background: #fff; + border: 1px solid $gray-200; + border-top: 3px solid $gold-500; border-radius: $r-lg; padding: 26px 30px; position: relative; overflow: hidden; - &::before { - content: ''; - position: absolute; - left: 0; right: 0; bottom: 0; - height: 2px; - background: linear-gradient(90deg, $gold-500, transparent 60%); - } } @mixin card-hover-gold { diff --git a/frontend/src/webparts/approvalAdmin/assets/welcome-dark.png b/frontend/src/webparts/approvalAdmin/assets/welcome-dark.png deleted file mode 100644 index 42f0b8d24a9aa964a2be4885fe5700c1c54191a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12545 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�Gp~kbx)oU28ew4C2+WtKA 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 G`2PSf1J4)$ diff --git a/frontend/src/webparts/approvalAdmin/assets/welcome-light.png b/frontend/src/webparts/approvalAdmin/assets/welcome-light.png deleted file mode 100644 index 69eb3b48cd83031f106df4b4df127c749657e319..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12816 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 diff --git a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx index 7064d0c..63902c0 100644 --- a/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx +++ b/frontend/src/webparts/approvalAdmin/components/ApprovalAdmin.tsx @@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp'; import { ApprovalRouteApi } from '../services/api'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { PortalDictionaryClient } from '../services/portalDictionary'; +import { SharePointListsClient } from '../services/sharePointLists'; import { texts } from '../texts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; @@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC = props => { const [api, setApi] = React.useState(); const [dictionary, setDictionary] = React.useState(); const [error, setError] = React.useState(); + const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]); React.useEffect(() => { ensureFontsLoaded(); @@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC = props => { )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index 514922f..474e25b 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -4,7 +4,8 @@ import { TopNav } from './TopNav'; import { ScreenKey } from './types'; import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; -import { TemplateRoute } from '../../models'; +import { SharePointListsClient } from '../../services/sharePointLists'; +import { RequestType, TemplateRoute } from '../../models'; import { DashboardScreen } from '../screens/DashboardScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RoutesScreen } from '../screens/RoutesScreen'; @@ -15,13 +16,14 @@ export interface IAdminAppProps { api: ApprovalRouteApi; /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ dictionary?: PortalDictionaryClient; + sharePoint: SharePointListsClient; currentUserEmail?: string; } -export const AdminApp: React.FC = ({ api, dictionary, currentUserEmail }) => { +export const AdminApp: React.FC = ({ api, dictionary, sharePoint, currentUserEmail }) => { const [screen, setScreen] = React.useState('dashboard'); const [selectedRoute, setSelectedRoute] = React.useState(); - const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); + const [selectedRequestType, setSelectedRequestType] = React.useState(); const navigate = (key: ScreenKey): void => { setScreen(key); @@ -29,9 +31,10 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse const openSteps = (route: TemplateRoute): void => { setSelectedRoute(route); + setSelectedRequestType(undefined); api.requestTypes.get(route.requestTypeId) - .then(rt => setSelectedRequestTypeName(rt.name)) - .catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`)); + .then(setSelectedRequestType) + .catch(() => setSelectedRequestType(undefined)); setScreen('routeSteps'); }; @@ -40,19 +43,20 @@ export const AdminApp: React.FC = ({ api, dictionary, currentUse
{screen === 'dashboard' && } - {screen === 'requestTypes' && } + {screen === 'requestTypes' && } {screen === 'routes' && } {screen === 'routeSteps' && selectedRoute && ( setScreen('routes')} /> )} {screen === 'resolve' && ( - + )}
diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss index 78e5d8c..f283b56 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss +++ b/frontend/src/webparts/approvalAdmin/components/app/TopNav.module.scss @@ -23,20 +23,6 @@ flex-shrink: 0; } -.mark { - width: 30px; - height: 30px; - border-radius: 7px; - background: $grad-gold-v; - display: flex; - align-items: center; - justify-content: center; - color: $navy-950; - font-weight: 800; - font-size: 13px; - font-family: $font-serif; -} - .brandText { color: #fff; font-weight: 600; diff --git a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx index 128a231..506ef05 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/TopNav.tsx @@ -72,7 +72,6 @@ export const TopNav: React.FC = ({ active, onSelect }) => { return (