3 Commits

Author SHA1 Message Date
gorbenko.pavel 06b52c5186 фронт поправлен 2026-07-31 12:51:48 +05:00
gorbenko.pavel fdd3787e43 добавлены новые поля 2026-07-31 11:54:16 +05:00
gorbenko.pavel ea2599732c переход на локальную базейку 2026-07-31 10:46:53 +05:00
31 changed files with 1108 additions and 85 deletions
@@ -6,9 +6,10 @@
} }
}, },
"ConnectionStrings": { "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": { "Entra": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0" "Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
}, },
@@ -1,9 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes; 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);
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
{ {
var items = await db.RequestTypes.AsNoTracking() var items = await db.RequestTypes.AsNoTracking()
.OrderBy(x => x.Name) .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items); return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
@@ -29,7 +29,18 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken) public async Task<Result<RequestTypeDto>> 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<RequestTypeDto>(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); db.RequestTypes.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -42,8 +53,15 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id)); return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
if (!RequestTypeFields.IsListReferenceValid(request.ListName, request.SiteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name; entity.Name = request.Name;
entity.PageUrl = request.PageUrl; entity.PageUrl = request.PageUrl;
entity.ListName = request.ListName;
entity.SiteUrl = request.SiteUrl;
entity.Section = request.Section;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
@@ -55,8 +73,18 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id)); return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
var listName = request.ListName ?? entity.ListName;
var siteUrl = request.SiteUrl ?? entity.SiteUrl;
if (!RequestTypeFields.IsListReferenceValid(listName, siteUrl))
return Result.Fail<RequestTypeDto>(new ValidationError(
"Имя списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе."));
entity.Name = request.Name ?? entity.Name; entity.Name = request.Name ?? entity.Name;
entity.PageUrl = request.PageUrl ?? entity.PageUrl; entity.PageUrl = request.PageUrl ?? entity.PageUrl;
entity.ListName = listName;
entity.SiteUrl = siteUrl;
entity.Section = request.Section ?? entity.Section;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
@@ -74,5 +102,5 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok(); 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);
} }
@@ -8,6 +8,10 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
{ {
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); 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<Update
{ {
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно."); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); 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<PatchRe
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым.")); RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
When(x => x.PageUrl is not null, () => When(x => x.PageUrl is not null, () =>
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым.")); 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);
}
@@ -12,6 +12,9 @@ public sealed record TemplateRouteStepDto(
RouteStepStatus Status, RouteStepStatus Status,
int RouteId, int RouteId,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value,
string? Warning = null); string? Warning = null);
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
@@ -21,7 +24,10 @@ public sealed record CreateTemplateRouteStepRequest(
ProjectRole? ProjectRole, ProjectRole? ProjectRole,
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
public sealed record UpdateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
@@ -30,7 +36,10 @@ public sealed record UpdateTemplateRouteStepRequest(
ProjectRole? ProjectRole, ProjectRole? ProjectRole,
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
public sealed record PatchTemplateRouteStepRequest( public sealed record PatchTemplateRouteStepRequest(
int? OrderNo, int? OrderNo,
@@ -39,4 +48,7 @@ public sealed record PatchTemplateRouteStepRequest(
ProjectRole? ProjectRole, ProjectRole? ProjectRole,
HeadType? HeadType, HeadType? HeadType,
RouteStepStatus? Status, RouteStepStatus? Status,
bool? AllowReassignToOtherApprovers); bool? AllowReassignToOtherApprovers,
string? FieldName,
ComparisonOperator? Operation,
string? Value);
@@ -15,7 +15,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
.Where(x => x.RouteId == routeId) .Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo) .OrderBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto( .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); .ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items); return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
@@ -33,7 +34,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken) public async Task<Result<TemplateRouteStepDto>> 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) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
@@ -41,6 +42,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken); route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
if (duplicate) if (duplicate)
@@ -56,7 +65,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
ProjectRole = r.ProjectRole, ProjectRole = r.ProjectRole,
HeadType = r.HeadType, HeadType = r.HeadType,
Status = r.Status, Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
FieldName = r.FieldName,
Operation = r.Operation,
Value = r.Value
}; };
db.TemplateRouteSteps.Add(entity); db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -67,7 +79,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public async Task<Result<TemplateRouteStepDto>> UpdateAsync( public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken) 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) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
@@ -79,6 +91,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresListName(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken); route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
@@ -92,6 +112,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.HeadType = r.HeadType; entity.HeadType = r.HeadType;
entity.Status = r.Status; entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
entity.FieldName = r.FieldName;
entity.Operation = r.Operation;
entity.Value = r.Value;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
@@ -100,7 +123,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
public async Task<Result<TemplateRouteStepDto>> PatchAsync( public async Task<Result<TemplateRouteStepDto>> PatchAsync(
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken) 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) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
@@ -113,6 +136,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
var projectRole = r.ProjectRole ?? entity.ProjectRole; var projectRole = r.ProjectRole ?? entity.ProjectRole;
var headType = r.HeadType ?? entity.HeadType; var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status; 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) if (status == RouteStepStatus.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution.")); return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
@@ -125,6 +151,14 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); "Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
if (RequiresListName(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListName))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано имя списка (list_name)."));
var duplicate = await HasDuplicateAsync( var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
if (duplicate) if (duplicate)
@@ -138,6 +172,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
entity.HeadType = headType; entity.HeadType = headType;
entity.Status = status; entity.Status = status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers; entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
entity.FieldName = fieldName;
entity.Operation = operation;
entity.Value = value;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
@@ -155,6 +192,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok(); return Result.Ok();
} }
private static bool RequiresListName(string? fieldName, ComparisonOperator? operation, string? value) =>
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
private Task<bool> HasDuplicateAsync( private Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType, int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) => RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
@@ -184,5 +224,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
} }
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new( 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);
} }
@@ -11,6 +11,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .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) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .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) + (string.IsNullOrWhiteSpace(position) ? 0 : 1) +
(projectRole is null or ProjectRole.None ? 0 : 1) + (projectRole is null or ProjectRole.None ? 0 : 1) +
(headType is null or HeadType.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;
}
} }
@@ -5,6 +5,9 @@ public class RequestType
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string PageUrl { 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<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>(); public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
} }
@@ -16,5 +16,9 @@ public class TemplateRouteStep
public int RouteId { get; set; } public int RouteId { get; set; }
public bool AllowReassignToOtherApprovers { 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; } public TemplateRoute? Route { get; set; }
} }
@@ -0,0 +1,9 @@
namespace Tnsu.ApprovalRoute.Domain.Enums;
public enum ComparisonOperator
{
None,
GreaterThan,
LessThan,
Equal
}
@@ -16,6 +16,9 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.Property(x => x.Name).HasMaxLength(256).IsRequired(); e.Property(x => x.Name).HasMaxLength(256).IsRequired();
e.Property(x => x.PageUrl).HasMaxLength(512).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<TemplateRoute>(e => modelBuilder.Entity<TemplateRoute>(e =>
@@ -34,6 +37,9 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32); e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32); e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.FieldName).HasMaxLength(128);
e.Property(x => x.Operation).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Value).HasMaxLength(256);
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
e.HasIndex(x => new { x.RouteId, x.OrderNo }); e.HasIndex(x => new { x.RouteId, x.OrderNo });
}); });
@@ -0,0 +1,223 @@
// <auto-generated />
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("20260731073536_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ListName")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("list_name");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("PageUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject")
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid")
.HasColumnName("department_id");
b.Property<int>("RequestTypeId")
.HasColumnType("integer")
.HasColumnName("request_type_id");
b.Property<bool>("RequiresControl")
.HasColumnType("boolean")
.HasColumnName("requires_control");
b.Property<string>("RouteType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("route_type");
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers");
b.Property<string>("ExecutorEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("executor_email");
b.Property<string>("FieldName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("HeadType")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<string>("Operation")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo")
.HasColumnType("integer")
.HasColumnName("order_no");
b.Property<string>("Position")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("position");
b.Property<string>("ProjectRole")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("project_role");
b.Property<int>("RouteId")
.HasColumnType("integer")
.HasColumnName("route_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("status");
b.Property<string>("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
}
}
}
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRequestTypeListNameSectionAndStepConditionFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "field_name",
table: "template_route_steps",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "operation",
table: "template_route_steps",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "value",
table: "template_route_steps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "list_name",
table: "request_types",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "section",
table: "request_types",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "site_url",
table: "request_types",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
/// <inheritdoc />
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");
}
}
}
@@ -31,6 +31,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ListName")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("list_name");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -43,6 +48,17 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
.HasColumnName("page_url"); .HasColumnName("page_url");
b.Property<string>("Section")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("section");
b.Property<string>("SiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("site_url");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_request_types"); .HasName("pk_request_types");
@@ -112,11 +128,21 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("FieldName")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("field_name");
b.Property<string>("HeadType") b.Property<string>("HeadType")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("head_type"); .HasColumnName("head_type");
b.Property<string>("Operation")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("operation");
b.Property<int>("OrderNo") b.Property<int>("OrderNo")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("order_no"); .HasColumnName("order_no");
@@ -141,6 +167,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("status"); .HasColumnName("status");
b.Property<string>("Value")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("value");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_template_route_steps"); .HasName("pk_template_route_steps");
@@ -13,7 +13,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity() public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
{ {
// Action // 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 // Assert
created.IsSuccess.ShouldBeTrue(); created.IsSuccess.ShouldBeTrue();
@@ -43,11 +43,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task UpdateAsync_ChangesFields() public async Task UpdateAsync_ChangesFields()
{ {
// Arrange // 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 // Action
var updated = await _service.UpdateAsync( 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 // Assert
updated.IsSuccess.ShouldBeTrue(); updated.IsSuccess.ShouldBeTrue();
@@ -60,7 +60,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.UpdateAsync( var result = await _service.UpdateAsync(
999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None); 999999, new UpdateRequestTypeRequest("X", "/x", null, null, "General"), CancellationToken.None);
// Assert // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -70,11 +70,11 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task PatchAsync_UpdatesOnlyProvidedFields() public async Task PatchAsync_UpdatesOnlyProvidedFields()
{ {
// Arrange // 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 // Action
var patched = await _service.PatchAsync( 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 // Assert
patched.IsSuccess.ShouldBeTrue(); patched.IsSuccess.ShouldBeTrue();
@@ -87,7 +87,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
{ {
// Action // Action
var result = await _service.PatchAsync( 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 // Assert
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
@@ -97,7 +97,7 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
public async Task DeleteAsync_RemovesEntity() public async Task DeleteAsync_RemovesEntity()
{ {
// Arrange // 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 // Action
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
@@ -109,11 +109,37 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
fetched.IsFailed.ShouldBeTrue(); 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 = "Список типов заявок включает ранее созданный тип")] [Fact(DisplayName = "Список типов заявок включает ранее созданный тип")]
public async Task ListAsync_IncludesCreatedEntity() public async Task ListAsync_IncludesCreatedEntity()
{ {
// Arrange // 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 // Action
var list = await _service.ListAsync(CancellationToken.None); var list = await _service.ListAsync(CancellationToken.None);
@@ -38,7 +38,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
} }
private async Task<int> CreateRequestTypeAsync(string name) => private async Task<int> 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;
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
@@ -163,7 +163,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -196,22 +196,22 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -240,7 +240,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -272,13 +272,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -311,13 +311,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -346,13 +346,13 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = services.GetRequiredService<TemplateRouteStepService>(); var stepService = services.GetRequiredService<TemplateRouteStepService>();
var requestTypeId = (await requestTypeService.CreateAsync( 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( var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -379,7 +379,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -407,7 +407,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -435,7 +435,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, 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); CancellationToken.None);
// Action // Action
@@ -24,10 +24,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
_requestTypeService = services.GetRequiredService<RequestTypeService>(); _requestTypeService = services.GetRequiredService<RequestTypeService>();
} }
private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject) private async Task<int> 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( 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( var route = await _routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false), new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false),
@@ -42,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -58,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -70,14 +71,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false); var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None); var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var duplicate = await _service.CreateAsync( var duplicate = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true), new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -93,7 +94,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
// Assert // Assert
@@ -111,7 +112,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -131,7 +132,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -149,7 +150,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -164,14 +165,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -188,14 +189,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step", byProject: false); var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -212,14 +213,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
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); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -233,14 +234,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false); var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -254,14 +255,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
var patched = await _service.PatchAsync( var patched = await _service.PatchAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null), new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, FieldName: null, Operation: null, Value: null),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -269,6 +270,78 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.Value.ExecutorEmail.ShouldBe("a@b.kz"); patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
} }
[Fact(DisplayName = "Создание шага с частично заполненными полями условия возвращает неуспешный результат")]
public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests");
// Action
var result = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", null, null),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага с условием на типе заявки без list_name возвращает неуспешный результат")]
public async Task CreateAsync_ConditionFieldsWithoutListName_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route without list name", byProject: false);
// Action
var result = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага с условием на типе заявки со list_name успешно")]
public async Task CreateAsync_ConditionFieldsWithListName_ReturnsCreatedStep()
{
// Arrange
var routeId = await CreateRouteAsync("Route with list name", byProject: false, listName: "Requests");
// Action
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.FieldName.ShouldBe("Amount");
created.Value.Operation.ShouldBe(ComparisonOperator.GreaterThan);
created.Value.Value.ShouldBe("1000");
}
[Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")]
public async Task PatchAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
// Action
var patched = await _service.PatchAsync(
routeId,
created.Value.Id,
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, FieldName: "Amount", Operation: null, Value: null),
CancellationToken.None);
// Assert
patched.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")] [Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesStep() public async Task DeleteAsync_RemovesStep()
{ {
@@ -276,7 +349,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None); CancellationToken.None);
// Action // Action
+71
View File
@@ -11,10 +11,12 @@
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-core-library": "1.22.1",
"@microsoft/sp-http": "1.22.1",
"@microsoft/sp-lodash-subset": "1.22.1", "@microsoft/sp-lodash-subset": "1.22.1",
"@microsoft/sp-office-ui-fabric-core": "1.22.1", "@microsoft/sp-office-ui-fabric-core": "1.22.1",
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@pnp/sp": "^4.20.0",
"react": "17.0.1", "react": "17.0.1",
"react-dom": "17.0.1", "react-dom": "17.0.1",
"tslib": "2.3.1" "tslib": "2.3.1"
@@ -5051,6 +5053,75 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/@pnp/core": {
"version": "4.20.0",
"resolved": "https://registry.npmjs.org/@pnp/core/-/core-4.20.0.tgz",
"integrity": "sha512-Pq421YYRxAEF3PEKSnhIpz1M5yqmehdmidn+0ImYKixI9V2UQgoWUohcb7bA2DiHXcM8HJl2nF/62PUmBUVAaQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=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": { "node_modules/@rushstack/debug-certificate-manager": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz", "resolved": "https://registry.npmjs.org/@rushstack/debug-certificate-manager/-/debug-certificate-manager-1.6.2.tgz",
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@fluentui/react": "^8.106.4", "@fluentui/react": "^8.106.4",
"@pnp/sp": "^4.20.0",
"@microsoft/sp-component-base": "1.22.1", "@microsoft/sp-component-base": "1.22.1",
"@microsoft/sp-core-library": "1.22.1", "@microsoft/sp-core-library": "1.22.1",
"@microsoft/sp-http": "1.22.1", "@microsoft/sp-http": "1.22.1",
@@ -5,6 +5,7 @@ import { AdminApp } from './app/AdminApp';
import { ApprovalRouteApi } from '../services/api'; import { ApprovalRouteApi } from '../services/api';
import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient'; import { AadApiHttpClient, DevApiHttpClient, IHttpClient } from '../services/httpClient';
import { PortalDictionaryClient } from '../services/portalDictionary'; import { PortalDictionaryClient } from '../services/portalDictionary';
import { SharePointListsClient } from '../services/sharePointLists';
import { texts } from '../texts'; import { texts } from '../texts';
const FONTS_LINK_ID = 'tnsu-approval-admin-fonts'; const FONTS_LINK_ID = 'tnsu-approval-admin-fonts';
@@ -30,6 +31,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
const [api, setApi] = React.useState<ApprovalRouteApi | undefined>(); const [api, setApi] = React.useState<ApprovalRouteApi | undefined>();
const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>(); const [dictionary, setDictionary] = React.useState<PortalDictionaryClient | undefined>();
const [error, setError] = React.useState<string | undefined>(); const [error, setError] = React.useState<string | undefined>();
const sharePoint = React.useMemo(() => new SharePointListsClient(props.context), [props.context]);
React.useEffect(() => { React.useEffect(() => {
ensureFontsLoaded(); ensureFontsLoaded();
@@ -80,6 +82,7 @@ const ApprovalAdmin: React.FC<IApprovalAdminProps> = props => {
<AdminApp <AdminApp
api={api} api={api}
dictionary={dictionary} dictionary={dictionary}
sharePoint={sharePoint}
currentUserEmail={props.context.pageContext.user.email} currentUserEmail={props.context.pageContext.user.email}
/> />
)} )}
@@ -4,7 +4,8 @@ import { TopNav } from './TopNav';
import { ScreenKey } from './types'; import { ScreenKey } from './types';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; 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 { DashboardScreen } from '../screens/DashboardScreen';
import { RequestTypesScreen } from '../screens/RequestTypesScreen'; import { RequestTypesScreen } from '../screens/RequestTypesScreen';
import { RoutesScreen } from '../screens/RoutesScreen'; import { RoutesScreen } from '../screens/RoutesScreen';
@@ -15,13 +16,14 @@ export interface IAdminAppProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
currentUserEmail?: string; currentUserEmail?: string;
} }
export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUserEmail }) => { export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, sharePoint, currentUserEmail }) => {
const [screen, setScreen] = React.useState<ScreenKey>('dashboard'); const [screen, setScreen] = React.useState<ScreenKey>('dashboard');
const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>(); const [selectedRoute, setSelectedRoute] = React.useState<TemplateRoute | undefined>();
const [selectedRequestTypeName, setSelectedRequestTypeName] = React.useState(''); const [selectedRequestType, setSelectedRequestType] = React.useState<RequestType | undefined>();
const navigate = (key: ScreenKey): void => { const navigate = (key: ScreenKey): void => {
setScreen(key); setScreen(key);
@@ -29,9 +31,10 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
const openSteps = (route: TemplateRoute): void => { const openSteps = (route: TemplateRoute): void => {
setSelectedRoute(route); setSelectedRoute(route);
setSelectedRequestType(undefined);
api.requestTypes.get(route.requestTypeId) api.requestTypes.get(route.requestTypeId)
.then(rt => setSelectedRequestTypeName(rt.name)) .then(setSelectedRequestType)
.catch(() => setSelectedRequestTypeName(`#${route.requestTypeId}`)); .catch(() => setSelectedRequestType(undefined));
setScreen('routeSteps'); setScreen('routeSteps');
}; };
@@ -40,14 +43,15 @@ export const AdminApp: React.FC<IAdminAppProps> = ({ api, dictionary, currentUse
<TopNav active={screen} onSelect={navigate} /> <TopNav active={screen} onSelect={navigate} />
<div className={styles.content}> <div className={styles.content}>
{screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />} {screen === 'dashboard' && <DashboardScreen api={api} onNavigate={navigate} />}
{screen === 'requestTypes' && <RequestTypesScreen api={api} />} {screen === 'requestTypes' && <RequestTypesScreen api={api} sharePoint={sharePoint} />}
{screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />} {screen === 'routes' && <RoutesScreen api={api} dictionary={dictionary} onOpenSteps={openSteps} />}
{screen === 'routeSteps' && selectedRoute && ( {screen === 'routeSteps' && selectedRoute && (
<RouteStepsScreen <RouteStepsScreen
api={api} api={api}
route={selectedRoute} route={selectedRoute}
requestTypeName={selectedRequestTypeName} requestType={selectedRequestType}
dictionary={dictionary} dictionary={dictionary}
sharePoint={sharePoint}
onBack={() => setScreen('routes')} onBack={() => setScreen('routes')}
/> />
)} )}
@@ -1,8 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType } from '../../models'; import { RequestType } from '../../models';
import { texts } from '../../texts'; import { texts } from '../../texts';
import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useRequestTypes } from '../../hooks/useRequestTypes';
import { useSharePointLists } from '../../hooks/useSharePointLists';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useDeleteTarget } from '../../hooks/useDeleteTarget'; import { useDeleteTarget } from '../../hooks/useDeleteTarget';
import { import {
@@ -18,27 +20,33 @@ import {
IconButton, IconButton,
PageHeader, PageHeader,
PrimaryCell, PrimaryCell,
Select,
TextInput TextInput
} from '../ui'; } from '../ui';
export interface IRequestTypesScreenProps { export interface IRequestTypesScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
sharePoint: SharePointListsClient;
} }
interface IFormState { interface IFormState {
name: string; name: string;
pageUrl: string; pageUrl: string;
section: string;
siteUrl: string;
listName: string;
} }
const EMPTY_FORM: IFormState = { name: '', pageUrl: '' }; const EMPTY_FORM: IFormState = { name: '', pageUrl: '', section: '', siteUrl: '', listName: '' };
export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api }) => { export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api, sharePoint }) => {
const list = useRequestTypes(api); const list = useRequestTypes(api);
const [editing, setEditing] = React.useState<RequestType | undefined>(); const [editing, setEditing] = React.useState<RequestType | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
const [form, setForm] = React.useState<IFormState>(EMPTY_FORM); const [form, setForm] = React.useState<IFormState>(EMPTY_FORM);
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const sharePointLists = useSharePointLists(sharePoint, form.siteUrl);
const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload); const deleteTarget = useDeleteTarget<RequestType>(item => api.requestTypes.remove(item.id), list.reload);
@@ -50,16 +58,35 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
const openEdit = (item: RequestType): void => { const openEdit = (item: RequestType): void => {
setEditing(item); setEditing(item);
setForm({ name: item.name, pageUrl: item.pageUrl }); setForm({
name: item.name,
pageUrl: item.pageUrl,
section: item.section,
siteUrl: item.siteUrl ?? '',
listName: item.listName ?? ''
});
setShowForm(true); setShowForm(true);
}; };
const submitForm = (): void => { const submitForm = (): void => {
if (!form.name.trim() || !form.pageUrl.trim()) { if (!form.name.trim() || !form.pageUrl.trim() || !form.section.trim()) {
submit.fail(texts.requestTypes.validationRequired); submit.fail(texts.requestTypes.validationRequired);
return; return;
} }
const input = { name: form.name.trim(), pageUrl: form.pageUrl.trim() }; const siteUrl = form.siteUrl.trim();
const listName = form.listName.trim();
if (Boolean(siteUrl) !== Boolean(listName)) {
submit.fail(texts.requestTypes.validationListReference);
return;
}
const input = {
name: form.name.trim(),
pageUrl: form.pageUrl.trim(),
section: form.section.trim(),
siteUrl: siteUrl || null,
listName: listName || null
};
const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input); const request = editing ? api.requestTypes.update(editing.id, input) : api.requestTypes.create(input);
submit.run(() => submit.run(() =>
@@ -70,6 +97,8 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
); );
}; };
const listNameOptions = sharePointLists.items.map(l => ({ value: l.title, label: l.title }));
return ( return (
<div> <div>
<Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} /> <Breadcrumbs items={[{ label: texts.common.home }, { label: texts.requestTypes.breadcrumb }]} />
@@ -87,6 +116,7 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
columns={[ columns={[
{ key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> }, { key: 'name', header: texts.requestTypes.columnName, render: r => <PrimaryCell>{r.name}</PrimaryCell> },
{ key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl }, { key: 'url', header: texts.requestTypes.columnPageUrl, render: r => r.pageUrl },
{ key: 'section', header: texts.requestTypes.columnSection, render: r => r.section },
{ {
key: 'actions', key: 'actions',
header: '', header: '',
@@ -129,6 +159,41 @@ export const RequestTypesScreen: React.FC<IRequestTypesScreenProps> = ({ api })
placeholder={texts.requestTypes.pageUrlPlaceholder} placeholder={texts.requestTypes.pageUrlPlaceholder}
/> />
</Field> </Field>
<Field label={texts.requestTypes.sectionLabel} required full>
<TextInput
value={form.section}
onChange={e => setForm({ ...form, section: e.target.value })}
placeholder={texts.requestTypes.sectionPlaceholder}
/>
</Field>
<Field label={texts.requestTypes.siteUrlLabel} full hint={texts.requestTypes.siteUrlHint}>
<TextInput
value={form.siteUrl}
onChange={e => setForm({ ...form, siteUrl: e.target.value, listName: '' })}
placeholder={texts.requestTypes.siteUrlPlaceholder}
/>
</Field>
<Field
label={texts.requestTypes.listNameLabel}
full
hint={sharePointLists.error ? texts.requestTypes.listNameError(sharePointLists.error) : texts.requestTypes.listNameHint}
>
<Select
options={listNameOptions}
value={form.listName}
disabled={!form.siteUrl.trim() || sharePointLists.isLoading}
placeholder={
!form.siteUrl.trim()
? texts.requestTypes.listNamePlaceholder
: sharePointLists.isLoading
? texts.requestTypes.listNameLoading
: listNameOptions.length === 0
? texts.requestTypes.listNameEmpty
: texts.select.placeholder
}
onChange={e => setForm({ ...form, listName: e.target.value })}
/>
</Field>
</FormGrid> </FormGrid>
</FormModal> </FormModal>
)} )}
@@ -1,23 +1,30 @@
import * as React from 'react'; import * as React from 'react';
import { import {
ComparisonOperator,
Employee, Employee,
HeadType, HeadType,
ProjectRole, ProjectRole,
RequestType,
RouteStepStatus, RouteStepStatus,
StepExecutorKind, StepExecutorKind,
TemplateRoute, TemplateRoute,
TemplateRouteStep TemplateRouteStep
} from '../../models'; } from '../../models';
import { headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts'; import { comparisonOperatorLabels, headTypeLabels, projectRoleLabels, routeStepStatusLabels, texts } from '../../texts';
import { detectExecutorKind } from '../../utils/stepExecutor'; import { detectExecutorKind } from '../../utils/stepExecutor';
import { toEmployeeOption } from '../../utils/employee'; import { toEmployeeOption } from '../../utils/employee';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit'; import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useSharePointFields } from '../../hooks/useSharePointFields';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { SharePointListsClient } from '../../services/sharePointLists';
import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui'; import { Checkbox, Field, FormGrid, FormModal, RadioGroup, SearchableSelect, Select, TextInput } from '../ui';
export interface IRouteStepFormModalProps { export interface IRouteStepFormModalProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
/** Undefined, пока грузится тип заявки маршрута. */
requestType?: RequestType;
sharePoint: SharePointListsClient;
/** Undefined — создание нового шага, иначе редактирование существующего. */ /** Undefined — создание нового шага, иначе редактирование существующего. */
editing?: TemplateRouteStep; editing?: TemplateRouteStep;
nextOrderNo: number; nextOrderNo: number;
@@ -36,6 +43,10 @@ interface IFormState {
projectRole: ProjectRole; projectRole: ProjectRole;
headType: HeadType; headType: HeadType;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
hasCondition: boolean;
fieldName: string;
operation: ComparisonOperator;
value: string;
} }
const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution] const STATUS_OPTIONS = [RouteStepStatus.Approval, RouteStepStatus.Execution]
@@ -49,6 +60,10 @@ const HEAD_TYPE_OPTIONS = Object.values(HeadType)
.filter(v => v !== HeadType.None) .filter(v => v !== HeadType.None)
.map(v => ({ value: v, label: headTypeLabels[v] })); .map(v => ({ value: v, label: headTypeLabels[v] }));
const OPERATION_OPTIONS = Object.values(ComparisonOperator)
.filter(v => v !== ComparisonOperator.None)
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState {
if (!editing) { if (!editing) {
return { return {
@@ -59,7 +74,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: '', position: '',
projectRole: ProjectRole.ProjectManager, projectRole: ProjectRole.ProjectManager,
headType: HeadType.Chief, headType: HeadType.Chief,
allowReassignToOtherApprovers: false allowReassignToOtherApprovers: false,
hasCondition: false,
fieldName: '',
operation: ComparisonOperator.GreaterThan,
value: ''
}; };
} }
return { return {
@@ -70,7 +89,11 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
position: editing.position ?? '', position: editing.position ?? '',
projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager, projectRole: editing.projectRole && editing.projectRole !== ProjectRole.None ? editing.projectRole : ProjectRole.ProjectManager,
headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief, headType: editing.headType && editing.headType !== HeadType.None ? editing.headType : HeadType.Chief,
allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers allowReassignToOtherApprovers: editing.allowReassignToOtherApprovers,
hasCondition: Boolean(editing.fieldName),
fieldName: editing.fieldName ?? '',
operation: editing.operation && editing.operation !== ComparisonOperator.None ? editing.operation : ComparisonOperator.GreaterThan,
value: editing.value ?? ''
}; };
} }
@@ -78,6 +101,8 @@ function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number
export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
api, api,
route, route,
requestType,
sharePoint,
editing, editing,
nextOrderNo, nextOrderNo,
employees, employees,
@@ -87,6 +112,16 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
}) => { }) => {
const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo)); const [form, setForm] = React.useState<IFormState>(() => toFormState(editing, nextOrderNo));
const submit = useAsyncSubmit(); const submit = useAsyncSubmit();
const hasListName = Boolean(requestType?.listName);
const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl, requestType?.listName);
const fieldNameOptions = React.useMemo(() => {
const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title }));
if (form.fieldName && !options.some(o => o.value === form.fieldName)) {
options.unshift({ value: form.fieldName, label: form.fieldName });
}
return options;
}, [sharePointFields.items, form.fieldName]);
const submitForm = (): void => { const submitForm = (): void => {
const orderNo = Number(form.orderNo); const orderNo = Number(form.orderNo);
@@ -102,6 +137,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
submit.fail(texts.routeSteps.validationPosition); submit.fail(texts.routeSteps.validationPosition);
return; return;
} }
if (form.hasCondition && (!form.fieldName.trim() || !form.value.trim())) {
submit.fail(texts.routeSteps.validationCondition);
return;
}
const payload = { const payload = {
orderNo, orderNo,
@@ -110,7 +149,10 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null, executorEmail: form.kind === 'email' ? form.executorEmail.trim() : null,
position: form.kind === 'position' ? form.position.trim() : null, position: form.kind === 'position' ? form.position.trim() : null,
projectRole: form.kind === 'projectRole' ? form.projectRole : null, projectRole: form.kind === 'projectRole' ? form.projectRole : null,
headType: form.kind === 'headType' ? form.headType : null headType: form.kind === 'headType' ? form.headType : null,
fieldName: form.hasCondition ? form.fieldName.trim() : null,
operation: form.hasCondition ? form.operation : null,
value: form.hasCondition ? form.value.trim() : null
}; };
const request = editing const request = editing
@@ -217,6 +259,54 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })} onChange={v => setForm({ ...form, allowReassignToOtherApprovers: v })}
/> />
</Field> </Field>
<Field label={texts.routeSteps.conditionLabel} full hint={!hasListName ? texts.routeSteps.conditionDisabledHint : undefined}>
<Checkbox
label={texts.routeSteps.conditionCheckbox}
checked={form.hasCondition}
disabled={!hasListName}
onChange={v => setForm({ ...form, hasCondition: v })}
/>
</Field>
{form.hasCondition && hasListName && (
<>
<Field
label={texts.routeSteps.conditionFieldNameLabel}
required
full
hint={sharePointFields.error ? texts.routeSteps.conditionFieldNameError(sharePointFields.error) : undefined}
>
<Select
options={fieldNameOptions}
value={form.fieldName}
disabled={sharePointFields.isLoading}
placeholder={
sharePointFields.isLoading
? texts.routeSteps.conditionFieldNameLoading
: fieldNameOptions.length === 0
? texts.routeSteps.conditionFieldNameEmpty
: texts.routeSteps.conditionFieldNamePlaceholder
}
onChange={e => setForm({ ...form, fieldName: e.target.value })}
/>
</Field>
<Field label={texts.routeSteps.conditionOperationLabel} required>
<Select
options={OPERATION_OPTIONS}
value={form.operation}
onChange={e => setForm({ ...form, operation: e.target.value as ComparisonOperator })}
/>
</Field>
<Field label={texts.routeSteps.conditionValueLabel} required>
<TextInput
value={form.value}
onChange={e => setForm({ ...form, value: e.target.value })}
placeholder={texts.routeSteps.conditionValuePlaceholder}
/>
</Field>
</>
)}
</FormGrid> </FormGrid>
</FormModal> </FormModal>
); );
@@ -2,7 +2,8 @@ import * as React from 'react';
import { RouteStepFormModal } from './RouteStepFormModal'; import { RouteStepFormModal } from './RouteStepFormModal';
import { ApprovalRouteApi } from '../../services/api'; import { ApprovalRouteApi } from '../../services/api';
import { PortalDictionaryClient } from '../../services/portalDictionary'; import { PortalDictionaryClient } from '../../services/portalDictionary';
import { RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models'; import { SharePointListsClient } from '../../services/sharePointLists';
import { RequestType, RouteStepStatus, TemplateRoute, TemplateRouteStep } from '../../models';
import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts'; import { routeStepStatusLabels, routeTypeLabels, texts } from '../../texts';
import { describeStepExecutor } from '../../utils/stepExecutor'; import { describeStepExecutor } from '../../utils/stepExecutor';
import { useRouteSteps } from '../../hooks/useRouteSteps'; import { useRouteSteps } from '../../hooks/useRouteSteps';
@@ -25,16 +26,19 @@ import {
export interface IRouteStepsScreenProps { export interface IRouteStepsScreenProps {
api: ApprovalRouteApi; api: ApprovalRouteApi;
route: TemplateRoute; route: TemplateRoute;
requestTypeName: string; /** Undefined, пока грузится тип заявки (см. AdminApp.openSteps). */
requestType?: RequestType;
/** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */
dictionary?: PortalDictionaryClient; dictionary?: PortalDictionaryClient;
sharePoint: SharePointListsClient;
onBack: () => void; onBack: () => void;
} }
export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestTypeName, dictionary, onBack }) => { export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route, requestType, dictionary, sharePoint, onBack }) => {
const steps = useRouteSteps(api, route.id); const steps = useRouteSteps(api, route.id);
const employees = usePortalEmployees(dictionary); const employees = usePortalEmployees(dictionary);
const [savedWarning, setSavedWarning] = React.useState<string | undefined>(); const [savedWarning, setSavedWarning] = React.useState<string | undefined>();
const requestTypeName = requestType?.name ?? `#${route.requestTypeId}`;
const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>(); const [editing, setEditing] = React.useState<TemplateRouteStep | undefined>();
const [showForm, setShowForm] = React.useState(false); const [showForm, setShowForm] = React.useState(false);
@@ -140,6 +144,8 @@ export const RouteStepsScreen: React.FC<IRouteStepsScreenProps> = ({ api, route,
<RouteStepFormModal <RouteStepFormModal
api={api} api={api}
route={route} route={route}
requestType={requestType}
sharePoint={sharePoint}
editing={editing} editing={editing}
nextOrderNo={nextOrderNo} nextOrderNo={nextOrderNo}
employees={employees.items} employees={employees.items}
@@ -0,0 +1,41 @@
import * as React from 'react';
import { SharePointListField, SharePointListsClient } from '../services/sharePointLists';
export interface SharePointFieldsState {
items: SharePointListField[];
isLoading: boolean;
error?: string;
}
/** Заголовки столбцов списка (site_url + list_name типа заявки) — источник значений для field_name шага. */
export function useSharePointFields(
client: SharePointListsClient | undefined,
siteUrl: string | undefined | null,
listTitle: string | undefined | null
): SharePointFieldsState {
const [items, setItems] = React.useState<SharePointListField[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
if (!client || !siteUrl?.trim() || !listTitle?.trim()) {
setItems([]);
setError(undefined);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
client.listFields(siteUrl.trim(), listTitle.trim())
.then(result => { if (!cancelled) setItems(result); })
.catch((e: Error) => { if (!cancelled) { setItems([]); setError(e.message); } })
.finally(() => { if (!cancelled) setIsLoading(false); });
return () => { cancelled = true; };
}, [client, siteUrl, listTitle]);
return { items, isLoading, error };
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import { SharePointList, SharePointListsClient } from '../services/sharePointLists';
export interface SharePointListsState {
items: SharePointList[];
isLoading: boolean;
error?: string;
}
const DEBOUNCE_MS = 500;
/**
* Списки указанного сайта SharePoint — подгружаются автоматически по мере ввода
* site_url (с задержкой, чтобы не дёргать сайт на каждый символ).
*/
export function useSharePointLists(client: SharePointListsClient | undefined, siteUrl: string): SharePointListsState {
const [items, setItems] = React.useState<SharePointList[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
React.useEffect(() => {
const trimmed = siteUrl.trim();
if (!client || !trimmed) {
setItems([]);
setError(undefined);
setIsLoading(false);
return undefined;
}
let cancelled = false;
setIsLoading(true);
setError(undefined);
const timer = setTimeout(() => {
client.listLists(trimmed)
.then(result => { if (!cancelled) setItems(result); })
.catch((e: Error) => { if (!cancelled) { setItems([]); setError(e.message); } })
.finally(() => { if (!cancelled) setIsLoading(false); });
}, DEBOUNCE_MS);
return () => { cancelled = true; clearTimeout(timer); };
}, [client, siteUrl]);
return { items, isLoading, error };
}
@@ -28,3 +28,10 @@ export enum HeadType {
DirectionHead = 'DirectionHead', DirectionHead = 'DirectionHead',
DirectorTansu = 'DirectorTansu' DirectorTansu = 'DirectorTansu'
} }
export enum ComparisonOperator {
None = 'None',
GreaterThan = 'GreaterThan',
LessThan = 'LessThan',
Equal = 'Equal'
}
@@ -4,11 +4,19 @@ export interface RequestType {
id: number; id: number;
name: string; name: string;
pageUrl: string; pageUrl: string;
/** Имя списка SharePoint (Title) — источник field_name для шагов маршрута. */
listName: string | null;
/** Сайт SharePoint, где живёт listName. Заполнен тогда и только тогда, когда заполнен listName. */
siteUrl: string | null;
section: string;
} }
export interface CreateRequestTypeInput { export interface CreateRequestTypeInput {
name: string; name: string;
pageUrl: string; pageUrl: string;
listName: string | null;
siteUrl: string | null;
section: string;
} }
export type UpdateRequestTypeInput = CreateRequestTypeInput; export type UpdateRequestTypeInput = CreateRequestTypeInput;
@@ -16,4 +24,7 @@ export type UpdateRequestTypeInput = CreateRequestTypeInput;
export interface PatchRequestTypeInput { export interface PatchRequestTypeInput {
name?: string; name?: string;
pageUrl?: string; pageUrl?: string;
listName?: string | null;
siteUrl?: string | null;
section?: string;
} }
@@ -1,7 +1,7 @@
// DTO эндпоинта api/template-routes/{routeId}/steps // DTO эндпоинта api/template-routes/{routeId}/steps
// (Application/TemplateRouteSteps/*.cs). // (Application/TemplateRouteSteps/*.cs).
import { HeadType, ProjectRole, RouteStepStatus } from './enums'; import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus } from './enums';
export interface TemplateRouteStep { export interface TemplateRouteStep {
id: number; id: number;
@@ -13,6 +13,11 @@ export interface TemplateRouteStep {
status: RouteStepStatus; status: RouteStepStatus;
routeId: number; routeId: number;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
/** Столбец списка SharePoint (internal name), по которому проверяется условие. */
fieldName: string | null;
operation: ComparisonOperator | null;
/** Значение для сравнения с fieldName. */
value: string | null;
/** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */ /** Предупреждение бэкенда о сохранённом шаге (например, должность не найдена). */
warning?: string | null; warning?: string | null;
} }
@@ -25,6 +30,9 @@ export interface CreateTemplateRouteStepInput {
headType: HeadType | null; headType: HeadType | null;
status: RouteStepStatus; status: RouteStepStatus;
allowReassignToOtherApprovers: boolean; allowReassignToOtherApprovers: boolean;
fieldName: string | null;
operation: ComparisonOperator | null;
value: string | null;
} }
export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput; export type UpdateTemplateRouteStepInput = CreateTemplateRouteStepInput;
@@ -37,6 +45,9 @@ export interface PatchTemplateRouteStepInput {
headType?: HeadType | null; headType?: HeadType | null;
status?: RouteStepStatus; status?: RouteStepStatus;
allowReassignToOtherApprovers?: boolean; allowReassignToOtherApprovers?: boolean;
fieldName?: string | null;
operation?: ComparisonOperator | null;
value?: string | null;
} }
// Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено // Способ задать исполнителя шага: ровно одно из полей выше должно быть заполнено
@@ -0,0 +1,61 @@
import { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/fields';
import { WebPartContext } from '@microsoft/sp-webpart-base';
// Списки, из которых типы заявок берут list_name/field_name, живут на произвольном
// сайте SharePoint (разном для разных типов заявок) — не обязательно на том, где
// размещён сам веб-парт. Поэтому клиент всегда создаётся под конкретный site_url
// (spfi(siteUrl).using(SPFx(context))), а не через общий getSP() под текущий сайт.
// См. приложенный референс (sp-lists-frontend.md), раздел 8 «Другой сайт».
export interface SharePointList {
id: string;
title: string;
}
export interface SharePointListField {
internalName: string;
title: string;
}
interface IListItem {
Id: string;
Title: string;
}
interface IFieldItem {
InternalName: string;
Title: string;
}
function clientFor(context: WebPartContext, siteUrl: string): SPFI {
return spfi(siteUrl).using(SPFx(context));
}
export class SharePointListsClient {
constructor(private readonly context: WebPartContext) {}
/** Списки (не скрытые, обычные — BaseTemplate 100) на указанном сайте. */
public async listLists(siteUrl: string): Promise<SharePointList[]> {
const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists
.select('Id', 'Title')
.filter('Hidden eq false and BaseTemplate eq 100')
.orderBy('Title')() as IListItem[];
return items.map(x => ({ id: x.Id, title: x.Title }));
}
/** Заголовки столбцов (внутреннее + отображаемое имя) списка — исключая служебные поля. */
public async listFields(siteUrl: string, listTitle: string): Promise<SharePointListField[]> {
const sp = clientFor(this.context, siteUrl);
const items = await sp.web.lists.getByTitle(listTitle).fields
.select('InternalName', 'Title')
.filter('Hidden eq false and ReadOnlyField eq false')
.orderBy('Title')() as IFieldItem[];
return items.map(x => ({ internalName: x.InternalName, title: x.Title }));
}
}
+34 -2
View File
@@ -4,7 +4,7 @@
// //
// Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались. // Строки с подстановкой оформлены функциями, чтобы шаблон и данные не разъезжались.
import { HeadType, ProjectRole, RouteStepStatus, RouteType } from './models'; import { ComparisonOperator, HeadType, ProjectRole, RouteStepStatus, RouteType } from './models';
import { pluralRu } from './utils/plural'; import { pluralRu } from './utils/plural';
// ---- Подписи enum'ов ---- // ---- Подписи enum'ов ----
@@ -38,6 +38,13 @@ export const headTypeLabels: Record<HeadType, string> = {
[HeadType.DirectorTansu]: 'Директор Tansu' [HeadType.DirectorTansu]: 'Директор Tansu'
}; };
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
[ComparisonOperator.None]: '—',
[ComparisonOperator.GreaterThan]: 'Больше (>)',
[ComparisonOperator.LessThan]: 'Меньше (<)',
[ComparisonOperator.Equal]: 'Равно (=)'
};
export const texts = { export const texts = {
common: { common: {
home: 'Главная', home: 'Главная',
@@ -96,6 +103,7 @@ export const texts = {
emptyList: 'Типов заявок пока нет.', emptyList: 'Типов заявок пока нет.',
columnName: 'Название', columnName: 'Название',
columnPageUrl: 'Url страницы', columnPageUrl: 'Url страницы',
columnSection: 'Раздел',
createTitle: 'Новый тип заявки', createTitle: 'Новый тип заявки',
editTitle: 'Редактировать тип заявки', editTitle: 'Редактировать тип заявки',
nameLabel: 'Название', nameLabel: 'Название',
@@ -103,7 +111,19 @@ export const texts = {
pageUrlLabel: 'Url страницы', pageUrlLabel: 'Url страницы',
pageUrlHint: 'Страница формы заявки на портале.', pageUrlHint: 'Страница формы заявки на портале.',
pageUrlPlaceholder: '/pages/business-trip', pageUrlPlaceholder: '/pages/business-trip',
validationRequired: 'Заполните имя и Url страницы.', sectionLabel: 'Раздел',
sectionPlaceholder: 'Например, Кадры',
siteUrlLabel: 'Сайт SharePoint',
siteUrlHint: 'Сайт, где живёт список — например, https://tnsukz.sharepoint.com/sites/hrm.',
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/hrm',
listNameLabel: 'Список SharePoint',
listNameHint: 'Источник полей (field_name) для условий в шагах маршрута.',
listNamePlaceholder: 'Сначала укажите сайт SharePoint',
listNameLoading: 'Загрузка списков сайта...',
listNameEmpty: 'На этом сайте нет доступных списков',
listNameError: (message: string): string => `Не удалось получить списки сайта: ${message}`,
validationRequired: 'Заполните имя, Url страницы и раздел.',
validationListReference: 'Сайт SharePoint и список должны быть заполнены оба вместе или не заполнены вовсе.',
deleteTitle: 'Удалить тип заявки?', deleteTitle: 'Удалить тип заявки?',
deleteText: (name: string): string => deleteText: (name: string): string =>
`Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.` `Тип «${name}» будет удалён безвозвратно. Связанные маршруты могут перестать разрешаться.`
@@ -187,9 +207,21 @@ export const texts = {
headTypeLabel: 'Тип руководителя', headTypeLabel: 'Тип руководителя',
reassignLabel: 'Переназначение', reassignLabel: 'Переназначение',
reassignCheckbox: 'Разрешить переназначать другим согласующим', reassignCheckbox: 'Разрешить переназначать другим согласующим',
conditionLabel: 'Условие видимости шага',
conditionCheckbox: 'Показывать шаг только при выполнении условия',
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
conditionFieldNameLabel: 'Поле списка',
conditionFieldNamePlaceholder: 'Выберите столбец списка',
conditionFieldNameLoading: 'Загрузка столбцов списка...',
conditionFieldNameEmpty: 'В списке нет доступных столбцов',
conditionFieldNameError: (message: string): string => `Не удалось получить столбцы списка: ${message}`,
conditionOperationLabel: 'Оператор сравнения',
conditionValueLabel: 'Значение',
conditionValuePlaceholder: 'Например, 1000',
validationOrderNo: 'Порядковый номер шага обязателен.', validationOrderNo: 'Порядковый номер шага обязателен.',
validationExecutor: 'Выберите сотрудника-исполнителя.', validationExecutor: 'Выберите сотрудника-исполнителя.',
validationPosition: 'Укажите должность.', validationPosition: 'Укажите должность.',
validationCondition: 'Заполните поле списка, оператор и значение условия.',
deleteTitle: 'Удалить шаг?', deleteTitle: 'Удалить шаг?',
deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.` deleteText: (orderNo: number): string => `Шаг #${orderNo} будет удалён безвозвратно.`
}, },