Заглушка для департамента (#8)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
+37
@@ -56,6 +56,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: null, cancellationToken))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим."));
|
||||||
|
|
||||||
var entity = new TemplateRouteStep
|
var entity = new TemplateRouteStep
|
||||||
{
|
{
|
||||||
RouteId = route.Id,
|
RouteId = route.Id,
|
||||||
@@ -105,6 +109,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, excludeId: entity.Id, cancellationToken))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo;
|
entity.OrderNo = r.OrderNo;
|
||||||
entity.ExecutorEmail = r.ExecutorEmail;
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
entity.Position = r.Position;
|
entity.Position = r.Position;
|
||||||
@@ -165,6 +173,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
if (await HasDuplicateHeadTypeAsync(route.Id, headType, excludeId: entity.Id, cancellationToken))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
$"В маршруте уже есть шаг с типом руководителя «{headType}» — второй такой шаг недопустим."));
|
||||||
|
|
||||||
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
entity.ExecutorEmail = executorEmail;
|
entity.ExecutorEmail = executorEmail;
|
||||||
entity.Position = position;
|
entity.Position = position;
|
||||||
@@ -208,6 +220,31 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
|||||||
x.Status == status,
|
x.Status == status,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
|
||||||
|
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
|
||||||
|
/// DepartmentHead, единственный departmentId запроса (см.
|
||||||
|
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
|
||||||
|
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
|
||||||
|
/// в одного и того же (или неотличимо какого) руководителя — например, «два
|
||||||
|
/// разных директора одной и той же компании» просто не существуют. В отличие
|
||||||
|
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
|
||||||
|
/// достаточно самого факта совпадения HeadType — вне зависимости от статуса
|
||||||
|
/// или остальных полей шага.
|
||||||
|
/// </summary>
|
||||||
|
private Task<bool> HasDuplicateHeadTypeAsync(
|
||||||
|
int routeId, HeadType? headType, int? excludeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (headType is null or HeadType.None)
|
||||||
|
return Task.FromResult(false);
|
||||||
|
|
||||||
|
return db.TemplateRouteSteps.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RouteId == routeId &&
|
||||||
|
x.HeadType == headType,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||||
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
+9
-1
@@ -18,9 +18,17 @@ public sealed record ResolveTemplateRouteResultDto(
|
|||||||
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
|
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
|
||||||
public sealed record ResolveFieldValueDto(string FieldName, string Value);
|
public sealed record ResolveFieldValueDto(string FieldName, string Value);
|
||||||
|
|
||||||
|
/// <param name="DepartmentId">
|
||||||
|
/// Отдел для шагов с HeadType.DepartmentHead ("руководитель указанного отдела") —
|
||||||
|
/// не выводится из инициатора, поэтому должен быть передан явно. Необязателен:
|
||||||
|
/// если в маршруте нет такого шага, поле не нужно; если есть, а поле не
|
||||||
|
/// передано — шаг не резолвится и получает предупреждение (см.
|
||||||
|
/// TemplateRouteService.ResolveStepAsync), весь запрос при этом не падает.
|
||||||
|
/// </param>
|
||||||
public sealed record ResolveTemplateRouteRequest(
|
public sealed record ResolveTemplateRouteRequest(
|
||||||
int RequestTypeId,
|
int RequestTypeId,
|
||||||
string? UserEmail,
|
string? UserEmail,
|
||||||
Guid? ProjectId,
|
Guid? ProjectId,
|
||||||
RouteStepStatus Status,
|
RouteStepStatus Status,
|
||||||
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null);
|
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null,
|
||||||
|
Guid? DepartmentId = null);
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ public sealed class TemplateRouteService(
|
|||||||
|
|
||||||
var steps = new List<ResolvedRouteStepDto>();
|
var steps = new List<ResolvedRouteStepDto>();
|
||||||
foreach (var step in matchingSteps)
|
foreach (var step in matchingSteps)
|
||||||
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken));
|
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, request.DepartmentId, cancellationToken));
|
||||||
|
|
||||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ public sealed class TemplateRouteService(
|
|||||||
|
|
||||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string? resolvedEmail;
|
string? resolvedEmail;
|
||||||
string? warning = null;
|
string? warning = null;
|
||||||
@@ -233,6 +233,25 @@ public sealed class TemplateRouteService(
|
|||||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
warning = $"Не найден исполнитель с ролью в проекте «{step.ProjectRole}».";
|
||||||
}
|
}
|
||||||
|
// DepartmentHead — единственный HeadType, который резолвится НЕ по цепочке
|
||||||
|
// подчинённости инициатора, а по отделу, переданному явно в запросе (см.
|
||||||
|
// ResolveTemplateRouteRequest.DepartmentId): отдел инициатора тут ни при
|
||||||
|
// чём, шаг про «руководителя ТАКОГО-ТО отдела», а не «моего руководителя».
|
||||||
|
// Поэтому у него отдельная ветка, а не место в switch чуть ниже.
|
||||||
|
else if (step.HeadType == HeadType.DepartmentHead)
|
||||||
|
{
|
||||||
|
if (departmentId is null)
|
||||||
|
{
|
||||||
|
resolvedEmail = null;
|
||||||
|
warning = "Шаг требует руководителя указанного отдела, но departmentId не передан в запросе.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
resolvedEmail = await portalClient.GetDepartmentHeadEmailAsync(departmentId.Value, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
warning = $"Не найден руководитель отдела с id «{departmentId}».";
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (step.HeadType is not (null or HeadType.None))
|
else if (step.HeadType is not (null or HeadType.None))
|
||||||
{
|
{
|
||||||
resolvedEmail = step.HeadType switch
|
resolvedEmail = step.HeadType switch
|
||||||
|
|||||||
@@ -5,5 +5,14 @@ public enum HeadType
|
|||||||
None,
|
None,
|
||||||
Chief,
|
Chief,
|
||||||
DirectionHead,
|
DirectionHead,
|
||||||
DirectorTansu
|
DirectorTansu,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Руководитель отдела, указанного явно в запросе на resolve (см.
|
||||||
|
/// ResolveTemplateRouteRequest.DepartmentId), а не отдела инициатора —
|
||||||
|
/// этим отличается от Chief/DirectionHead, которые всегда идут по цепочке
|
||||||
|
/// подчинённости самого инициатора. В маршруте не может быть больше одного
|
||||||
|
/// шага с этим типом (см. TemplateRouteStepService).
|
||||||
|
/// </summary>
|
||||||
|
DepartmentHead
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
return executors;
|
return executors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// STUB(department-head): в api.tnsu.kz пока нет эндпоинта «руководитель отдела
|
||||||
|
// по Id отдела» — до его появления просто возвращаем захардкоженный email,
|
||||||
|
// чтобы можно было отладить всю остальную логику HeadType.DepartmentHead
|
||||||
|
// (resolve, warning-и, UI) не дожидаясь портала. Когда эндпоинт появится —
|
||||||
|
// заменить тело этого метода на реальный вызов через GetAsync<T> (по
|
||||||
|
// образцу остальных методов этого класса); сигнатуру и вызывающий код
|
||||||
|
// (TemplateRouteService.ResolveStepAsync) менять не придётся.
|
||||||
|
public Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult<string?>("gorbenko.pavel@tnsu.kz");
|
||||||
|
|
||||||
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
|
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ public interface ITnsuPortalClient
|
|||||||
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
|
||||||
|
Task<string?> GetDepartmentHeadEmailAsync(Guid departmentId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
+109
@@ -231,6 +231,115 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
|||||||
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")]
|
||||||
|
public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Department head without id");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action — departmentId в запросе не передан
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
|
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
||||||
|
resolved.Value.Steps[0].Warning.ShouldContain("departmentId");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» и переданным departmentId — резолвится через портал")]
|
||||||
|
public async Task ResolveAsync_DepartmentHeadStepWithDepartmentId_ResolvesViaPortalClient()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var targetDepartmentId = Guid.Parse("00000000-0000-0000-0000-0000000000aa");
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("head-of-department@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Department head resolved", "/department-head-resolved", null, null, "General"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, DepartmentId: targetDepartmentId),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("head-of-department@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].Warning.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела», для которого портал не вернул руководителя — предупреждение")]
|
||||||
|
public async Task ResolveAsync_DepartmentHeadNotFoundByPortal_ReturnsWarning()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var targetDepartmentId = Guid.Parse("00000000-0000-0000-0000-0000000000bb");
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((string?)null);
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Department head not found", "/department-head-not-found", null, null, "General"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, DepartmentId: targetDepartmentId),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull();
|
||||||
|
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
|
||||||
|
resolved.Value.Steps[0].Warning.ShouldContain(targetDepartmentId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
|
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
|
||||||
public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep()
|
public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep()
|
||||||
{
|
{
|
||||||
|
|||||||
+86
@@ -85,6 +85,92 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
|||||||
duplicate.IsFailed.ShouldBeTrue();
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")]
|
||||||
|
public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange — тип руководителя резолвится в ОДНОГО конкретного человека
|
||||||
|
// (см. HasDuplicateHeadTypeAsync), поэтому два шага с одним HeadType в
|
||||||
|
// одном маршруте не должны допускаться, даже если остальные поля разные.
|
||||||
|
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
|
||||||
|
var first = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
|
||||||
|
var second = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
second.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Два шага «Руководитель указанного отдела» в одном маршруте — конфликт")]
|
||||||
|
public async Task CreateAsync_TwoDepartmentHeadSteps_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
|
||||||
|
var first = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var second = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
second.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Разные типы руководителя в одном маршруте — допустимы (конфликт только при совпадении HeadType)")]
|
||||||
|
public async Task CreateAsync_DifferentHeadTypes_BothSucceed()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with different head types", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var chief = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
var directorTansu = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
chief.IsSuccess.ShouldBeTrue();
|
||||||
|
directorTansu.IsSuccess.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление шага, при котором HeadType не меняется, не конфликтует само с собой")]
|
||||||
|
public async Task UpdateAsync_SameHeadTypeOnSameStep_DoesNotConflictWithItself()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, null, null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
|
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
|
||||||
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
|
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { routeStepStatusLabels, texts } from '../../texts';
|
|||||||
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
import { useRequestTypes } from '../../hooks/useRequestTypes';
|
||||||
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
import { useResolveRoute } from '../../hooks/useResolveRoute';
|
||||||
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
import { usePortalEmployees } from '../../hooks/usePortalEmployees';
|
||||||
|
import { usePortalDictionaries } from '../../hooks/usePortalDictionaries';
|
||||||
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
import { useSharePointFields } from '../../hooks/useSharePointFields';
|
||||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||||
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
import { findEmployeeByEmail, toEmployeeOption } from '../../utils/employee';
|
||||||
@@ -56,6 +57,7 @@ interface IResolveFormState {
|
|||||||
projectLabel: string;
|
projectLabel: string;
|
||||||
status: RouteStepStatus;
|
status: RouteStepStatus;
|
||||||
fieldValues: ResolveFieldValue[];
|
fieldValues: ResolveFieldValue[];
|
||||||
|
departmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
||||||
@@ -64,13 +66,14 @@ const EMPTY_RESOLVE_FORM: IResolveFormState = {
|
|||||||
projectId: '',
|
projectId: '',
|
||||||
projectLabel: '',
|
projectLabel: '',
|
||||||
status: RouteStepStatus.Approval,
|
status: RouteStepStatus.Approval,
|
||||||
fieldValues: []
|
fieldValues: [],
|
||||||
|
departmentId: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail, route }) => {
|
export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary, sharePoint, currentUserEmail, route }) => {
|
||||||
const requestTypes = useRequestTypes(api);
|
const requestTypes = useRequestTypes(api);
|
||||||
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
const [form, setForm] = useLocalStorageState<IResolveFormState>(RESOLVE_FORM_STORAGE_KEY, EMPTY_RESOLVE_FORM);
|
||||||
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [] } = form;
|
const { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues = [], departmentId } = form;
|
||||||
|
|
||||||
// Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда
|
// Открыли по глубокой ссылке (aartype и т.д. в URL) — параметры оттуда
|
||||||
// перекрывают то, что было в localStorage. Только на монтировании: route
|
// перекрывают то, что было в localStorage. Только на монтировании: route
|
||||||
@@ -85,7 +88,8 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
projectId: route.projectId,
|
projectId: route.projectId,
|
||||||
projectLabel: route.projectLabel,
|
projectLabel: route.projectLabel,
|
||||||
status: route.status,
|
status: route.status,
|
||||||
fieldValues: route.fieldValues
|
fieldValues: route.fieldValues,
|
||||||
|
departmentId: route.departmentId
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -94,8 +98,11 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
// которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе
|
// которую можно скопировать/сохранить. replaceRoute, а не navigate: иначе
|
||||||
// каждый введённый символ добавлял бы шаг в историю браузера.
|
// каждый введённый символ добавлял бы шаг в историю браузера.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
replaceRoute({ tab: 'resolve', resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues } });
|
replaceRoute({
|
||||||
}, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues]);
|
tab: 'resolve',
|
||||||
|
resolve: { requestTypeId, userEmail, projectId, projectLabel, status, fieldValues, departmentId }
|
||||||
|
});
|
||||||
|
}, [requestTypeId, userEmail, projectId, projectLabel, status, fieldValues, departmentId]);
|
||||||
|
|
||||||
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
const requestType = requestTypes.items.find(rt => rt.id === Number(requestTypeId));
|
||||||
const hasListId = Boolean(requestType?.listId);
|
const hasListId = Boolean(requestType?.listId);
|
||||||
@@ -104,6 +111,9 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
|
|
||||||
const resolved = useResolveRoute(api);
|
const resolved = useResolveRoute(api);
|
||||||
const employees = usePortalEmployees(dictionary);
|
const employees = usePortalEmployees(dictionary);
|
||||||
|
// ~700 отделов — то же соображение, что и в RouteFormModal: дешевле грузить
|
||||||
|
// список один раз и искать по нему на клиенте, чем ходить в сеть на каждый символ.
|
||||||
|
const dictionaries = usePortalDictionaries(dictionary);
|
||||||
|
|
||||||
const searchProjects = React.useCallback(
|
const searchProjects = React.useCallback(
|
||||||
(query: string, page: number) => {
|
(query: string, page: number) => {
|
||||||
@@ -143,7 +153,8 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
userEmail: userEmail.trim() || undefined,
|
userEmail: userEmail.trim() || undefined,
|
||||||
projectId: projectId.trim() || undefined,
|
projectId: projectId.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
fieldValues: conditions.length > 0 ? conditions : undefined
|
fieldValues: conditions.length > 0 ? conditions : undefined,
|
||||||
|
departmentId: departmentId.trim() || undefined
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,6 +208,19 @@ export const ResolveScreen: React.FC<IResolveScreenProps> = ({ api, dictionary,
|
|||||||
onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
|
onChange={(nextId, item) => setForm({ ...form, projectId: nextId, projectLabel: item ? item.name : '' })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={texts.resolve.departmentLabel} hint={texts.resolve.departmentHint}>
|
||||||
|
<SearchableSelect
|
||||||
|
options={dictionaries.departments.map(d => ({ value: d.id, label: d.name, sublabel: d.company }))}
|
||||||
|
value={departmentId}
|
||||||
|
loading={dictionaries.isLoading}
|
||||||
|
allowClear
|
||||||
|
clearLabel={texts.resolve.departmentClear}
|
||||||
|
placeholder={texts.resolve.departmentPlaceholder}
|
||||||
|
searchPlaceholder={texts.resolve.departmentSearchPlaceholder}
|
||||||
|
emptyMessage={texts.resolve.departmentEmpty}
|
||||||
|
onChange={departmentId => setForm({ ...form, departmentId })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label={texts.resolve.statusLabel} required>
|
<Field label={texts.resolve.statusLabel} required>
|
||||||
<Select
|
<Select
|
||||||
options={STATUS_OPTIONS}
|
options={STATUS_OPTIONS}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export enum HeadType {
|
|||||||
None = 'None',
|
None = 'None',
|
||||||
Chief = 'Chief',
|
Chief = 'Chief',
|
||||||
DirectionHead = 'DirectionHead',
|
DirectionHead = 'DirectionHead',
|
||||||
DirectorTansu = 'DirectorTansu'
|
DirectorTansu = 'DirectorTansu',
|
||||||
|
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||||
|
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||||
|
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||||
|
DepartmentHead = 'DepartmentHead'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ComparisonOperator {
|
export enum ComparisonOperator {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface ResolveTemplateRouteQuery {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
status: RouteStepStatus;
|
status: RouteStepStatus;
|
||||||
fieldValues?: ResolveFieldValue[];
|
fieldValues?: ResolveFieldValue[];
|
||||||
|
/** Для шагов с HeadType.DepartmentHead — отдел не выводится из инициатора, задаётся явно. */
|
||||||
|
departmentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedRouteStep {
|
export interface ResolvedRouteStep {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { RouteStepStatus } from './models';
|
||||||
import { navigate, parseRoute } from './routing';
|
import { navigate, parseRoute } from './routing';
|
||||||
|
|
||||||
// buildQuery/navigate читают/пишут window.location через history API — сбрасываем
|
// buildQuery/navigate читают/пишут window.location через history API — сбрасываем
|
||||||
@@ -50,6 +51,16 @@ describe('parseRoute', () => {
|
|||||||
it('resolve не собирается без requestTypeId, даже если tab=resolve', () => {
|
it('resolve не собирается без requestTypeId, даже если tab=resolve', () => {
|
||||||
expect(parseRoute('?aatab=resolve').resolve).toBeUndefined();
|
expect(parseRoute('?aatab=resolve').resolve).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolve.departmentId читается из aadept (шаг «Руководитель указанного отдела»)', () => {
|
||||||
|
const route = parseRoute('?aatab=resolve&aartype=1&aadept=00000000-0000-0000-0000-0000000000aa');
|
||||||
|
expect(route.resolve?.departmentId).toBe('00000000-0000-0000-0000-0000000000aa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolve.departmentId пустой, если aadept не передан', () => {
|
||||||
|
const route = parseRoute('?aatab=resolve&aartype=1');
|
||||||
|
expect(route.resolve?.departmentId).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('navigate', () => {
|
describe('navigate', () => {
|
||||||
@@ -71,4 +82,21 @@ describe('navigate', () => {
|
|||||||
navigate({ tab: 'requestTypes' });
|
navigate({ tab: 'requestTypes' });
|
||||||
expect(window.history.length).toBe(initialLength + 1);
|
expect(window.history.length).toBe(initialLength + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trip для resolve.departmentId', () => {
|
||||||
|
navigate({
|
||||||
|
tab: 'resolve',
|
||||||
|
resolve: {
|
||||||
|
requestTypeId: '1',
|
||||||
|
userEmail: '',
|
||||||
|
projectId: '',
|
||||||
|
projectLabel: '',
|
||||||
|
status: RouteStepStatus.Approval,
|
||||||
|
fieldValues: [],
|
||||||
|
departmentId: '00000000-0000-0000-0000-0000000000aa'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const roundTripped = parseRoute(window.location.search);
|
||||||
|
expect(roundTripped.resolve?.departmentId).toBe('00000000-0000-0000-0000-0000000000aa');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface IResolveRouteParams {
|
|||||||
projectLabel: string;
|
projectLabel: string;
|
||||||
status: RouteStepStatus;
|
status: RouteStepStatus;
|
||||||
fieldValues: ResolveFieldValue[];
|
fieldValues: ResolveFieldValue[];
|
||||||
|
/**
|
||||||
|
* Для шагов с HeadType.DepartmentHead (см. ResolveTemplateRouteQuery.departmentId).
|
||||||
|
* Без отдельного departmentLabel — в отличие от projectLabel (AsyncSearchableSelect,
|
||||||
|
* пагинированный поиск), отделы выбираются из уже целиком загруженного справочника
|
||||||
|
* (SearchableSelect), так что подпись резолвится на месте по id, а не хранится отдельно.
|
||||||
|
*/
|
||||||
|
departmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IManageRequestRouteParams {
|
export interface IManageRequestRouteParams {
|
||||||
@@ -50,7 +57,7 @@ const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'ma
|
|||||||
|
|
||||||
const PARAM_KEYS = [
|
const PARAM_KEYS = [
|
||||||
'aatab', 'aaroute',
|
'aatab', 'aaroute',
|
||||||
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields',
|
'aartype', 'aauser', 'aaproject', 'aaprojectname', 'aastatus', 'aafields', 'aadept',
|
||||||
'aamtype', 'aamreq'
|
'aamtype', 'aamreq'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -92,7 +99,8 @@ export function parseRoute(search: string = window.location.search): IAdminRoute
|
|||||||
projectId: params.get('aaproject') || '',
|
projectId: params.get('aaproject') || '',
|
||||||
projectLabel: params.get('aaprojectname') || '',
|
projectLabel: params.get('aaprojectname') || '',
|
||||||
status: (params.get('aastatus') as RouteStepStatus) || RouteStepStatus.Approval,
|
status: (params.get('aastatus') as RouteStepStatus) || RouteStepStatus.Approval,
|
||||||
fieldValues: parseFieldValues(params.get('aafields'))
|
fieldValues: parseFieldValues(params.get('aafields')),
|
||||||
|
departmentId: params.get('aadept') || ''
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -130,6 +138,7 @@ function buildQuery(route: IAdminRoute): string {
|
|||||||
if (route.resolve.projectLabel) params.set('aaprojectname', route.resolve.projectLabel);
|
if (route.resolve.projectLabel) params.set('aaprojectname', route.resolve.projectLabel);
|
||||||
if (route.resolve.status) params.set('aastatus', route.resolve.status);
|
if (route.resolve.status) params.set('aastatus', route.resolve.status);
|
||||||
if (route.resolve.fieldValues.length > 0) params.set('aafields', JSON.stringify(route.resolve.fieldValues));
|
if (route.resolve.fieldValues.length > 0) params.set('aafields', JSON.stringify(route.resolve.fieldValues));
|
||||||
|
if (route.resolve.departmentId) params.set('aadept', route.resolve.departmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
if (route.tab === 'manageRequests' && route.manage && route.manage.requestTypeId && route.manage.requestId) {
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export const headTypeLabels: Record<HeadType, string> = {
|
|||||||
[HeadType.None]: '—',
|
[HeadType.None]: '—',
|
||||||
[HeadType.Chief]: 'Непосредственный руководитель',
|
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||||
[HeadType.DirectorTansu]: 'Директор Tansu'
|
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||||
|
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
export const comparisonOperatorLabels: Record<ComparisonOperator, string> = {
|
||||||
@@ -246,6 +247,12 @@ export const texts = {
|
|||||||
projectSearchPlaceholder: 'Поиск проекта по названию...',
|
projectSearchPlaceholder: 'Поиск проекта по названию...',
|
||||||
projectClear: 'Без проекта',
|
projectClear: 'Без проекта',
|
||||||
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
projectDictionaryLoading: 'Справочник проектов ещё загружается.',
|
||||||
|
departmentLabel: 'Отдел (для шага «Руководитель указанного отдела»)',
|
||||||
|
departmentHint: 'Заполните, если в маршруте есть шаг с типом руководителя «Руководитель указанного отдела».',
|
||||||
|
departmentPlaceholder: 'Не выбран',
|
||||||
|
departmentSearchPlaceholder: 'Поиск по названию отдела...',
|
||||||
|
departmentClear: 'Без отдела',
|
||||||
|
departmentEmpty: 'Отделы не найдены',
|
||||||
statusLabel: 'Статус',
|
statusLabel: 'Статус',
|
||||||
conditionsLabel: 'Условия (значения полей заявки)',
|
conditionsLabel: 'Условия (значения полей заявки)',
|
||||||
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
|
conditionsDisabledHint: 'Недоступно: у выбранного типа заявки не указан список SharePoint (list_name).',
|
||||||
|
|||||||
Reference in New Issue
Block a user