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 42f0b8d..0000000 Binary files a/frontend/src/webparts/approvalAdmin/assets/welcome-dark.png and /dev/null differ 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 69eb3b4..0000000 Binary files a/frontend/src/webparts/approvalAdmin/assets/welcome-light.png and /dev/null differ 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 (