Несколько условий шага, объединяемых через И/ИЛИ (#16)
<html> <body> <!--StartFragment--> GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors -- <!--EndFragment--> </body> </html>[GetEmployeeByEmailAsync возвращает массив; новый HeadType.CompanyDirectors](http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/commit/572749b4d7cc11bc8e1ef4184a93f18a14b710ab) --------- Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/16
This commit was merged in pull request #16.
This commit is contained in:
@@ -7,6 +7,9 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||
// записей, чтобы не ломать существующие позиционные вызовы.
|
||||
|
||||
/// <summary>Одно условие появления шага — см. Domain.Entities.TemplateRouteStepCondition.</summary>
|
||||
public sealed record RouteStepConditionDto(string FieldName, ComparisonOperator Operation, string Value);
|
||||
|
||||
public sealed record TemplateRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
@@ -17,9 +20,8 @@ public sealed record TemplateRouteStepDto(
|
||||
RouteStepStatus Status,
|
||||
int RouteId,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto> Conditions,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
@@ -32,9 +34,8 @@ public sealed record CreateTemplateRouteStepRequest(
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic = ConditionsLogic.And,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
@@ -46,12 +47,16 @@ public sealed record UpdateTemplateRouteStepRequest(
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic = ConditionsLogic.And,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
/// <summary>
|
||||
/// Как и остальные поля PATCH, Conditions=null означает «не менять». Отличить
|
||||
/// «не менять» от «убрать все условия» здесь нельзя (тот же приём, что и раньше
|
||||
/// для FieldName) — очистка условий делается через PUT с Conditions: [].
|
||||
/// </summary>
|
||||
public sealed record PatchTemplateRouteStepRequest(
|
||||
int? OrderNo,
|
||||
string? ExecutorEmail,
|
||||
@@ -60,8 +65,7 @@ public sealed record PatchTemplateRouteStepRequest(
|
||||
HeadType? HeadType,
|
||||
RouteStepStatus? Status,
|
||||
bool? AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
IReadOnlyList<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic? ConditionsLogic = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
+127
-84
@@ -15,14 +15,12 @@ public sealed class TemplateRouteStepService(
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Include(x => x.Conditions)
|
||||
.Where(x => x.RouteId == routeId)
|
||||
.OrderBy(x => x.OrderNo)
|
||||
.Select(x => new TemplateRouteStepDto(
|
||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
|
||||
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,19 +36,18 @@ public sealed class TemplateRouteStepService(
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
|
||||
|
||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Include(x => x.Conditions)
|
||||
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
|
||||
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo)
|
||||
.Select(x => new TemplateRouteStepDto(
|
||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
|
||||
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items.Select(ToDto).ToList());
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Include(x => x.Conditions)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
|
||||
return entity is null
|
||||
@@ -68,22 +65,22 @@ public sealed class TemplateRouteStepService(
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||
|
||||
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
|
||||
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||
|
||||
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
|
||||
var duplicate = await HasDuplicateAsync(
|
||||
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
|
||||
r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken);
|
||||
r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: null, cancellationToken))
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: null, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
@@ -97,9 +94,8 @@ public sealed class TemplateRouteStepService(
|
||||
HeadType = r.HeadType,
|
||||
Status = r.Status,
|
||||
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
|
||||
FieldName = r.FieldName,
|
||||
Operation = r.Operation,
|
||||
Value = r.Value,
|
||||
ConditionsLogic = r.ConditionsLogic,
|
||||
Conditions = ToEntities(r.Conditions),
|
||||
SlaHours = r.SlaHours,
|
||||
AskOverdueReason = r.AskOverdueReason
|
||||
};
|
||||
@@ -118,7 +114,8 @@ public sealed class TemplateRouteStepService(
|
||||
if (route is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||
|
||||
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||
|
||||
@@ -126,22 +123,22 @@ public sealed class TemplateRouteStepService(
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||
|
||||
if (!TemplateRouteStepFields.IsConditionValid(r.FieldName, r.Operation, r.Value))
|
||||
if (!TemplateRouteStepFields.AreConditionsValid(r.Conditions))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||
|
||||
if (RequiresList(r.FieldName, r.Operation, r.Value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
if (RequiresList(r.Conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
|
||||
var duplicate = await HasDuplicateAsync(
|
||||
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status,
|
||||
r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken);
|
||||
r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.FieldName, r.Operation, r.Value, excludeId: entity.Id, cancellationToken))
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, r.HeadType, r.Conditions, r.ConditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{r.HeadType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
@@ -152,9 +149,8 @@ public sealed class TemplateRouteStepService(
|
||||
entity.HeadType = r.HeadType;
|
||||
entity.Status = r.Status;
|
||||
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
||||
entity.FieldName = r.FieldName;
|
||||
entity.Operation = r.Operation;
|
||||
entity.Value = r.Value;
|
||||
entity.ConditionsLogic = r.ConditionsLogic;
|
||||
ReplaceConditions(entity, r.Conditions);
|
||||
entity.SlaHours = r.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -171,7 +167,8 @@ public sealed class TemplateRouteStepService(
|
||||
if (route is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||
|
||||
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
var entity = await db.TemplateRouteSteps.Include(x => x.Conditions)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||
if (entity is null)
|
||||
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||
|
||||
@@ -180,9 +177,8 @@ public sealed class TemplateRouteStepService(
|
||||
var projectRole = r.ProjectRole ?? entity.ProjectRole;
|
||||
var headType = r.HeadType ?? entity.HeadType;
|
||||
var status = r.Status ?? entity.Status;
|
||||
var fieldName = r.FieldName ?? entity.FieldName;
|
||||
var operation = r.Operation ?? entity.Operation;
|
||||
var value = r.Value ?? entity.Value;
|
||||
var conditions = r.Conditions ?? ToDtos(entity.Conditions);
|
||||
var conditionsLogic = r.ConditionsLogic ?? entity.ConditionsLogic;
|
||||
|
||||
if (status == RouteStepStatus.None)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError("Статус обязателен: Approval или Execution."));
|
||||
@@ -195,22 +191,22 @@ public sealed class TemplateRouteStepService(
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
|
||||
|
||||
if (!TemplateRouteStepFields.IsConditionValid(fieldName, operation, value))
|
||||
if (!TemplateRouteStepFields.AreConditionsValid(conditions))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе."));
|
||||
"В каждом условии (field_name, operation, value) все три поля обязательны."));
|
||||
|
||||
if (RequiresList(fieldName, operation, value) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
if (RequiresList(conditions) && string.IsNullOrWhiteSpace(route.RequestType?.ListId))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||
"Поля условия (field_name, operation, value) можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
"Условия появления шага можно указывать только если у типа заявки указано id списка (list_id)."));
|
||||
|
||||
var duplicate = await HasDuplicateAsync(
|
||||
route.Id, executorEmail, position, projectRole, headType, status,
|
||||
fieldName, operation, value, excludeId: entity.Id, cancellationToken);
|
||||
conditions, conditionsLogic, excludeId: entity.Id, cancellationToken);
|
||||
if (duplicate)
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя, статусом и условием появления уже существует в этом маршруте."));
|
||||
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, fieldName, operation, value, excludeId: entity.Id, cancellationToken))
|
||||
if (await HasDuplicateHeadTypeAsync(route.Id, headType, conditions, conditionsLogic, excludeId: entity.Id, cancellationToken))
|
||||
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||
$"В маршруте уже есть шаг с типом руководителя «{headType}» и таким же условием появления — второй такой шаг недопустим."));
|
||||
|
||||
@@ -221,9 +217,9 @@ public sealed class TemplateRouteStepService(
|
||||
entity.HeadType = headType;
|
||||
entity.Status = status;
|
||||
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
|
||||
entity.FieldName = fieldName;
|
||||
entity.Operation = operation;
|
||||
entity.Value = value;
|
||||
entity.ConditionsLogic = conditionsLogic;
|
||||
if (r.Conditions is not null)
|
||||
ReplaceConditions(entity, r.Conditions);
|
||||
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
|
||||
// наследованию SLA от типа заявки можно через PUT.
|
||||
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||
@@ -249,61 +245,108 @@ public sealed class TemplateRouteStepService(
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
private static bool RequiresList(string? fieldName, ComparisonOperator? operation, string? value) =>
|
||||
TemplateRouteStepFields.CountConditionFilled(fieldName, operation, value) > 0;
|
||||
private static bool RequiresList(IReadOnlyList<RouteStepConditionDto>? conditions) => conditions is { Count: > 0 };
|
||||
|
||||
private static List<TemplateRouteStepCondition> ToEntities(IReadOnlyList<RouteStepConditionDto>? conditions) =>
|
||||
(conditions ?? []).Select((c, i) => new TemplateRouteStepCondition
|
||||
{
|
||||
OrderNo = i,
|
||||
FieldName = c.FieldName,
|
||||
Operation = c.Operation,
|
||||
Value = c.Value
|
||||
}).ToList();
|
||||
|
||||
private static void ReplaceConditions(TemplateRouteStep entity, IReadOnlyList<RouteStepConditionDto>? conditions)
|
||||
{
|
||||
entity.Conditions.Clear();
|
||||
entity.Conditions.AddRange(ToEntities(conditions));
|
||||
}
|
||||
|
||||
private static List<RouteStepConditionDto> ToDtos(IEnumerable<TemplateRouteStepCondition> conditions) =>
|
||||
conditions.OrderBy(c => c.OrderNo).Select(c => new RouteStepConditionDto(c.FieldName, c.Operation, c.Value)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Шаги с одинаковым исполнителем считаются дубликатами только при одинаковом
|
||||
/// условии появления (field_name/operation/value): шаги с разными условиями
|
||||
/// взаимоисключающи по данным заявки, поэтому в один маршрут они попасть могут,
|
||||
/// а в один резолв — нет.
|
||||
/// условии появления (набор условий + логика их объединения): шаги с разными
|
||||
/// условиями взаимоисключающи по данным заявки, поэтому в один маршрут они
|
||||
/// попасть могут, а в один резолв — нет. Сравнение условий — в памяти
|
||||
/// (см. <see cref="ConditionsEqual"/>): кандидатов на дубликат в пределах
|
||||
/// одного маршрута всегда мало, а сравнение списка условий целиком плохо
|
||||
/// транслируется в SQL.
|
||||
/// </summary>
|
||||
private Task<bool> HasDuplicateAsync(
|
||||
private async Task<bool> HasDuplicateAsync(
|
||||
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
|
||||
RouteStepStatus status, string? fieldName, ComparisonOperator? operation, string? value,
|
||||
int? excludeId, CancellationToken cancellationToken) =>
|
||||
db.TemplateRouteSteps.AnyAsync(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RouteId == routeId &&
|
||||
x.ExecutorEmail == executorEmail &&
|
||||
x.Position == position &&
|
||||
x.ProjectRole == projectRole &&
|
||||
x. HeadType == headType &&
|
||||
x.Status == status &&
|
||||
x.FieldName == fieldName &&
|
||||
x.Operation == operation &&
|
||||
x.Value == value,
|
||||
cancellationToken);
|
||||
RouteStepStatus status, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||
int? excludeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
|
||||
.Where(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RouteId == routeId &&
|
||||
x.ExecutorEmail == executorEmail &&
|
||||
x.Position == position &&
|
||||
x.ProjectRole == projectRole &&
|
||||
x.HeadType == headType &&
|
||||
x.Status == status)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead) резолвится
|
||||
/// в ОДНОГО конкретного человека — цепочка подчинённости инициатора или, для
|
||||
/// DepartmentHead, единственный departmentId запроса (см.
|
||||
/// ResolveTemplateRouteRequest.DepartmentId) не оставляют места для выбора.
|
||||
/// Поэтому два шага одного маршрута с одинаковым HeadType всегда резолвились бы
|
||||
/// в одного и того же (или неотличимо какого) руководителя — например, «два
|
||||
/// разных директора одной и той же компании» просто не существуют. В отличие
|
||||
/// от HasDuplicateAsync (точное совпадение ВСЕХ полей включая статус), здесь
|
||||
/// достаточно совпадения HeadType — вне зависимости от статуса или остальных
|
||||
/// полей шага. Исключение — условие появления шага: при разных условиях
|
||||
/// в конкретный резолв попадёт только один из шагов, так что одного и того же
|
||||
/// руководителя дважды в маршруте не окажется.
|
||||
/// Любой HeadType (Chief/DirectionHead/DirectorTansu/DepartmentHead/CompanyDirectors)
|
||||
/// резолвится в один и тот же ДЕТЕРМИНИРОВАННЫЙ результат — цепочка подчинённости
|
||||
/// инициатора, единственный departmentId запроса (для DepartmentHead, см.
|
||||
/// ResolveTemplateRouteRequest.DepartmentId) или компании самого инициатора (для
|
||||
/// CompanyDirectors) не оставляют места для выбора. Для большинства значений это
|
||||
/// один конкретный человек; для CompanyDirectors — один и тот же набор людей
|
||||
/// (директор каждой компании инициатора), но тоже всегда один и тот же для
|
||||
/// одного и того же инициатора. Поэтому два шага одного маршрута с одинаковым
|
||||
/// HeadType всегда резолвились бы одинаково — например, «два разных директора
|
||||
/// одной и той же компании» просто не существуют. В отличие от HasDuplicateAsync
|
||||
/// (точное совпадение ВСЕХ полей включая статус), здесь достаточно совпадения
|
||||
/// HeadType — вне зависимости от статуса или остальных полей шага. Исключение —
|
||||
/// условие появления шага: при разных условиях в конкретный резолв попадёт только
|
||||
/// один из шагов, так что дублирования исполнителей в маршруте не окажется.
|
||||
/// </summary>
|
||||
private Task<bool> HasDuplicateHeadTypeAsync(
|
||||
int routeId, HeadType? headType, string? fieldName, ComparisonOperator? operation, string? value,
|
||||
private async Task<bool> HasDuplicateHeadTypeAsync(
|
||||
int routeId, HeadType? headType, IReadOnlyList<RouteStepConditionDto>? conditions, ConditionsLogic conditionsLogic,
|
||||
int? excludeId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (headType is null or HeadType.None)
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
|
||||
return db.TemplateRouteSteps.AnyAsync(x =>
|
||||
x.Id != excludeId &&
|
||||
x.RouteId == routeId &&
|
||||
x.HeadType == headType &&
|
||||
x.FieldName == fieldName &&
|
||||
x.Operation == operation &&
|
||||
x.Value == value,
|
||||
cancellationToken);
|
||||
var candidates = await db.TemplateRouteSteps.AsNoTracking().Include(x => x.Conditions)
|
||||
.Where(x => x.Id != excludeId && x.RouteId == routeId && x.HeadType == headType)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return candidates.Any(x => ConditionsEqual(ToDtos(x.Conditions), x.ConditionsLogic, conditions, conditionsLogic));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Наборы условий равны, если совпадают все условия (без учёта порядка — порядок
|
||||
/// влияет только на отображение в форме, не на вычисление) и, если условий два и
|
||||
/// более, логика их объединения (И/ИЛИ). При 0-1 условии логика ни на что не
|
||||
/// влияет, поэтому не сравнивается.
|
||||
/// </summary>
|
||||
private static bool ConditionsEqual(
|
||||
IReadOnlyList<RouteStepConditionDto> a, ConditionsLogic aLogic,
|
||||
IReadOnlyList<RouteStepConditionDto>? b, ConditionsLogic bLogic)
|
||||
{
|
||||
var bList = b ?? [];
|
||||
if (a.Count != bList.Count)
|
||||
return false;
|
||||
if (a.Count > 1 && aLogic != bLogic)
|
||||
return false;
|
||||
|
||||
static IEnumerable<(string FieldName, ComparisonOperator Operation, string Value)> Normalize(
|
||||
IReadOnlyList<RouteStepConditionDto> c) =>
|
||||
c.Select(x => (x.FieldName, x.Operation, x.Value))
|
||||
.OrderBy(x => x.FieldName, StringComparer.Ordinal)
|
||||
.ThenBy(x => x.Operation)
|
||||
.ThenBy(x => x.Value, StringComparer.Ordinal);
|
||||
|
||||
return Normalize(a).SequenceEqual(Normalize(bList));
|
||||
}
|
||||
|
||||
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||
@@ -323,5 +366,5 @@ public sealed class TemplateRouteStepService(
|
||||
|
||||
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.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason);
|
||||
ToDtos(e.Conditions), e.ConditionsLogic, null, e.SlaHours, e.AskOverdueReason);
|
||||
}
|
||||
|
||||
+14
-14
@@ -12,8 +12,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
|
||||
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
@@ -32,8 +32,8 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
|
||||
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
|
||||
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
@@ -51,14 +51,14 @@ internal static class TemplateRouteStepFields
|
||||
(projectRole is null or ProjectRole.None ? 0 : 1) +
|
||||
(headType is null or HeadType.None ? 0 : 1);
|
||||
|
||||
public static int CountConditionFilled(string? fieldName, ComparisonOperator? operation, string? value) =>
|
||||
(string.IsNullOrWhiteSpace(fieldName) ? 0 : 1) +
|
||||
(operation is null or ComparisonOperator.None ? 0 : 1) +
|
||||
(string.IsNullOrWhiteSpace(value) ? 0 : 1);
|
||||
|
||||
public static bool IsConditionValid(string? fieldName, ComparisonOperator? operation, string? value)
|
||||
{
|
||||
var count = CountConditionFilled(fieldName, operation, value);
|
||||
return count == 0 || count == 3;
|
||||
}
|
||||
/// <summary>
|
||||
/// Условие с пустым field_name/value или operation=None — половинчатое, а не
|
||||
/// «выключенное» (выключается вовсе не добавляя условие в список), поэтому
|
||||
/// такое отдельное условие недопустимо, даже если остальные условия шага корректны.
|
||||
/// </summary>
|
||||
public static bool AreConditionsValid(IReadOnlyList<RouteStepConditionDto>? conditions) =>
|
||||
conditions is null || conditions.All(c =>
|
||||
!string.IsNullOrWhiteSpace(c.FieldName) &&
|
||||
c.Operation != ComparisonOperator.None &&
|
||||
!string.IsNullOrWhiteSpace(c.Value));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
|
||||
/// </param>
|
||||
/// <param name="PositionCode">
|
||||
/// HeadType шага (Chief/DirectionHead/DirectorTansu/ManagingDirectorTansu/DepartmentHead),
|
||||
/// если исполнитель задан типом руководителя, иначе null. В отличие от <see cref="Position"/>
|
||||
/// (реальная должность резолвнутого сотрудника из справочника api.tnsu.kz — свободный
|
||||
/// текст), это код роли из самого шага маршрута — чтобы вызывающая сторона могла отличить,
|
||||
/// например, «директора Tansu» от «руководителя направления», не разбирая Position текстом.
|
||||
/// </param>
|
||||
public sealed record ResolvedRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
@@ -21,7 +28,8 @@ public sealed record ResolvedRouteStepDto(
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
bool? AskOverdueReason = null,
|
||||
HeadType? PositionCode = null);
|
||||
|
||||
public sealed record ResolveTemplateRouteResultDto(
|
||||
int RouteId,
|
||||
|
||||
@@ -142,18 +142,30 @@ public sealed class TemplateRouteService(
|
||||
if (requestType is null)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||
|
||||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||
if (employee is null)
|
||||
// /Employee/ByEmail отдаёт массив: сотрудник может числиться в нескольких
|
||||
// компаниях одновременно. Для поиска маршрута и большинства резолвов ниже
|
||||
// используем ПЕРВУЮ запись — так же, как раньше, когда портал отдавал ровно
|
||||
// одну. Все компании инициатора учитываются только в HeadType.CompanyDirectors
|
||||
// (см. ResolveStepAsync) — это единственный сценарий, которому они нужны все.
|
||||
var employees = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||
if (employees.Count == 0)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||||
|
||||
var primaryEmployee = employees[0];
|
||||
var initiatorCompanies = employees
|
||||
.Select(e => e.Company)
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var byProject = request.ProjectId is not null;
|
||||
|
||||
// Приоритет — маршрут с компанией инициатора; если для этой компании
|
||||
// своего маршрута нет, берём запасной маршрут без указанной компании
|
||||
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
|
||||
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
|
||||
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
|
||||
var route = await FindRouteAsync(request.RequestTypeId, primaryEmployee.CompanyId, primaryEmployee.DepartmentId, byProject, cancellationToken)
|
||||
?? await FindRouteAsync(request.RequestTypeId, null, primaryEmployee.DepartmentId, byProject, cancellationToken);
|
||||
|
||||
if (route is null)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||||
@@ -189,7 +201,7 @@ public sealed class TemplateRouteService(
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.Where(s => MatchesConditions(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.ToList();
|
||||
|
||||
@@ -200,17 +212,51 @@ public sealed class TemplateRouteService(
|
||||
// соответствует порядку задач, а не порядку завершения), поэтому
|
||||
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||||
// дубликат считается первым) продолжают работать так же, как при
|
||||
// последовательном foreach.
|
||||
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
||||
// последовательном foreach. Один шаг теперь может развернуться в НЕСКОЛЬКО
|
||||
// строк результата (HeadType.CompanyDirectors — по одной на компанию
|
||||
// инициатора), поэтому ResolveStepAsync возвращает список, а не одну dto,
|
||||
// и результат нужно сначала сгладить (SelectMany).
|
||||
var stepResults = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||
step, email, primaryEmployee.Company, initiatorCompanies, projectExecutors, request.DepartmentId, requestType,
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken)));
|
||||
|
||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||
var flattenedSteps = RemoveCompanyDirectorsDuplicatedByDirectorTansu(stepResults.SelectMany(x => x).ToList());
|
||||
var collapsedSteps = CollapseByExecutorEmail(flattenedSteps);
|
||||
|
||||
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Директор Tansu (HeadType.DirectorTansu) сплошь и рядом совпадает с директором
|
||||
/// ОДНОЙ из компаний инициатора (HeadType.CompanyDirectors) — обычно с ТОО TANSU
|
||||
/// Construction. Оба шага в маршруте законны сами по себе, но если так, из
|
||||
/// CompanyDirectors нужно убрать именно ЭТУ строку — не смешивать её с шагом
|
||||
/// DirectorTansu через обычное схлопывание по email (см. CollapseByExecutorEmail):
|
||||
/// тот считает разные PositionCode у одного email конфликтом и пишет
|
||||
/// предупреждение, а здесь это не конфликт, а одно и то же лицо, просто
|
||||
/// увиденное с двух сторон. Шаг DirectorTansu в маршруте обычно идёт последним
|
||||
/// (после всех CompanyDirectors), поэтому удаление дубликата из CompanyDirectors
|
||||
/// оставляет его на своём месте — в конце.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> RemoveCompanyDirectorsDuplicatedByDirectorTansu(
|
||||
IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var directorTansuEmails = steps
|
||||
.Where(s => s.PositionCode == HeadType.DirectorTansu && !string.IsNullOrWhiteSpace(s.ExecutorEmail))
|
||||
.Select(s => s.ExecutorEmail!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (directorTansuEmails.Count == 0)
|
||||
return steps;
|
||||
|
||||
return steps
|
||||
.Where(s => s.PositionCode != HeadType.CompanyDirectors
|
||||
|| string.IsNullOrWhiteSpace(s.ExecutorEmail)
|
||||
|| !directorTansuEmails.Contains(s.ExecutorEmail))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
|
||||
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
|
||||
@@ -220,7 +266,7 @@ public sealed class TemplateRouteService(
|
||||
private async Task<TemplateRoute?> FindRouteAsync(
|
||||
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
|
||||
await db.TemplateRoutes.AsNoTracking()
|
||||
.Include(x => x.Steps)
|
||||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||
.Where(x =>
|
||||
x.RequestTypeId == requestTypeId &&
|
||||
x.CompanyId == companyId &&
|
||||
@@ -228,7 +274,7 @@ public sealed class TemplateRouteService(
|
||||
x.DepartmentId == departmentId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
?? await db.TemplateRoutes.AsNoTracking()
|
||||
.Include(x => x.Steps)
|
||||
.Include(x => x.Steps).ThenInclude(s => s.Conditions)
|
||||
.Where(x =>
|
||||
x.RequestTypeId == requestTypeId &&
|
||||
x.CompanyId == companyId &&
|
||||
@@ -252,12 +298,104 @@ public sealed class TemplateRouteService(
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||
/// <summary>
|
||||
/// Название должности директора для конкретной компании — по умолчанию «Директор»,
|
||||
/// но в части компаний руководителя называют иначе (нет штатной единицы «Директор»),
|
||||
/// поэтому для HeadType.CompanyDirectors ищем по этому названию через
|
||||
/// GetEmployeeByPositionAsync, а не по захардкоженному email, как DirectorTansu/
|
||||
/// ManagingDirectorTansu. Список компаний-исключений захардкожен здесь же:
|
||||
/// отдельного источника правды для него в системе нет.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> DirectorPositionByCompany =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ТОО KazPromService"] = "Главный механик",
|
||||
["ТОО СТ Global"] = "Генеральный директор"
|
||||
};
|
||||
|
||||
private const string DefaultDirectorPosition = "Директор";
|
||||
|
||||
private static string DirectorPositionFor(string company) =>
|
||||
DirectorPositionByCompany.TryGetValue(company, out var position) ? position : DefaultDirectorPosition;
|
||||
|
||||
/// <summary>
|
||||
/// Резолвит один шаг маршрута. Обычно возвращает ровно одну строку — но для
|
||||
/// HeadType.CompanyDirectors может вернуть несколько (по одной на компанию
|
||||
/// инициатора), все с одним и тем же OrderNo: они и так значились бы в одном
|
||||
/// шаге, то есть параллельны по построению, а не потому что кто-то расставил
|
||||
/// им одинаковый порядок вручную.
|
||||
/// </summary>
|
||||
/// <param name="initiatorCompanies">
|
||||
/// ВСЕ компании инициатора (сотрудник может числиться в нескольких) — нужны
|
||||
/// только для HeadType.CompanyDirectors. Остальные ветки резолва по-прежнему
|
||||
/// используют одну <paramref name="initiatorCompany"/> (первую по счёту).
|
||||
/// </param>
|
||||
/// <param name="conditionWarning">
|
||||
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<IReadOnlyList<ResolvedRouteStepDto>> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany, IReadOnlyList<string> initiatorCompanies,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
string? conditionWarning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
async Task<ResolvedRouteStepDto> BuildDtoAsync(string? resolvedEmail, string? warning)
|
||||
{
|
||||
string? resolvedPosition = null;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
{
|
||||
var resolvedEmployees = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||
resolvedPosition = resolvedEmployees.FirstOrDefault()?.Position;
|
||||
}
|
||||
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason,
|
||||
step.HeadType is null or HeadType.None ? null : step.HeadType);
|
||||
}
|
||||
|
||||
// CompanyDirectors — единственная ветка, которая резолвится в несколько строк
|
||||
// (по директору на каждую компанию инициатора), поэтому обрабатывается отдельно
|
||||
// и до общего "одна строка на шаг" пути ниже.
|
||||
if (step.HeadType == HeadType.CompanyDirectors)
|
||||
{
|
||||
if (initiatorCompanies.Count == 0)
|
||||
return [await BuildDtoAsync(null, "Не удалось определить ни одной компании инициатора для поиска директоров.")];
|
||||
|
||||
var dtos = new List<ResolvedRouteStepDto>();
|
||||
foreach (var company in initiatorCompanies)
|
||||
{
|
||||
var directorPosition = DirectorPositionFor(company);
|
||||
var employeeByPosition = await portalClient.GetEmployeeByPositionAsync(directorPosition, company, cancellationToken);
|
||||
var directorEmail = employeeByPosition.Email;
|
||||
string? directorWarning = null;
|
||||
if (!employeeByPosition.Found)
|
||||
directorWarning = $"Не найден сотрудник на должности «{directorPosition}» в компании «{company}».";
|
||||
else if (string.IsNullOrWhiteSpace(directorEmail))
|
||||
directorWarning = $"Сотрудник {employeeByPosition.Fio} на должности «{directorPosition}» в компании «{company}» найден, но email не указан в системе портала.";
|
||||
|
||||
dtos.Add(await BuildDtoAsync(directorEmail, directorWarning));
|
||||
}
|
||||
|
||||
return dtos;
|
||||
}
|
||||
|
||||
string? resolvedEmail;
|
||||
string? warning = null;
|
||||
|
||||
@@ -318,47 +456,33 @@ public sealed class TemplateRouteService(
|
||||
warning = "Не удалось определить исполнителя шага: не заполнено ни одно из полей.";
|
||||
}
|
||||
|
||||
string? resolvedPosition = null;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
{
|
||||
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||
resolvedPosition = resolvedEmployee?.Position;
|
||||
}
|
||||
|
||||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason);
|
||||
return [await BuildDtoAsync(resolvedEmail, warning)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
|
||||
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
|
||||
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
|
||||
/// сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||
/// Условия шага против значений полей, переданных в resolve-запросе. Шаг без условий
|
||||
/// применяется всегда. Несколько условий объединяются через <see cref="TemplateRouteStep.ConditionsLogic"/>:
|
||||
/// И — должны выполниться все, ИЛИ — достаточно одного. Условие, для которого не передано
|
||||
/// значение поля (fieldValues его не содержит), считается невыполненным — при resolve мы
|
||||
/// моделируем конкретный сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||
/// </summary>
|
||||
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||
private static bool MatchesConditions(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None)
|
||||
if (step.Conditions.Count == 0)
|
||||
return true;
|
||||
|
||||
if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||
return step.ConditionsLogic == ConditionsLogic.Or
|
||||
? step.Conditions.Any(c => MatchesCondition(c, fieldValues))
|
||||
: step.Conditions.All(c => MatchesCondition(c, fieldValues));
|
||||
}
|
||||
|
||||
private static bool MatchesCondition(TemplateRouteStepCondition condition, IReadOnlyDictionary<string, string> fieldValues)
|
||||
{
|
||||
if (!fieldValues.TryGetValue(condition.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||
return false;
|
||||
|
||||
var expected = step.Value;
|
||||
return step.Operation switch
|
||||
var expected = condition.Value;
|
||||
return condition.Operation switch
|
||||
{
|
||||
ComparisonOperator.Equal => IsEqual(actual, expected),
|
||||
ComparisonOperator.NotEqual => !IsEqual(actual, expected),
|
||||
@@ -399,14 +523,14 @@ public sealed class TemplateRouteService(
|
||||
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность
|
||||
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
|
||||
/// предупреждением — см. ResolveAsync.
|
||||
/// Одно из условий шага завязано на IsManager, но вычислить его не из чего: должность
|
||||
/// в заявке не передана. Такой шаг попадает в результат вопреки условиям и с
|
||||
/// предупреждением — см. ResolveAsync. Не различаем И/ИЛИ и остальные условия шага:
|
||||
/// раз про IsManager мы не знаем правду, итоговый матч шага в принципе ненадёжен.
|
||||
/// </summary>
|
||||
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
|
||||
positionMissing &&
|
||||
string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) &&
|
||||
step.Operation is not (null or ComparisonOperator.None);
|
||||
step.Conditions.Any(c => string.Equals(c.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Должность — свободный текст без структуры (нет отдельного флага «это
|
||||
@@ -423,17 +547,50 @@ public sealed class TemplateRouteService(
|
||||
!string.IsNullOrWhiteSpace(position) &&
|
||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Один и тот же исполнитель, полученный из разных шагов, в результате должен
|
||||
/// остаться один раз (иначе он одобрял бы одну заявку дважды) — но PositionCode
|
||||
/// схлопнутой строки не должен молча потеряться. Если у уже сохранённой строки
|
||||
/// он пуст, а у дубликата задан — переносим его на сохранённую строку. Если он
|
||||
/// задан у обеих строк и они РАЗНЫЕ (один и тот же email получен через две
|
||||
/// несовместимые роли шага, например Chief и CompanyDirectors одновременно) —
|
||||
/// это не должно молча выбрать одну из них без следа: добавляем предупреждение
|
||||
/// и оставляем любую (первую) — потерять исполнителя из-за такой неоднозначности
|
||||
/// хуже, чем показать чуть неточный PositionCode с пояснением.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var indexByEmail = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var result = new List<ResolvedRouteStepDto>();
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||||
if (string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||
{
|
||||
result.Add(step);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(step);
|
||||
if (!indexByEmail.TryGetValue(step.ExecutorEmail, out var index))
|
||||
{
|
||||
indexByEmail[step.ExecutorEmail] = result.Count;
|
||||
result.Add(step);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = result[index];
|
||||
if (existing.PositionCode is null && step.PositionCode is not null)
|
||||
{
|
||||
result[index] = existing with { PositionCode = step.PositionCode };
|
||||
}
|
||||
else if (existing.PositionCode is not null && step.PositionCode is not null && existing.PositionCode != step.PositionCode)
|
||||
{
|
||||
var conflictWarning =
|
||||
$"Исполнитель «{step.ExecutorEmail}» получен через разные роли шага ({existing.PositionCode} и {step.PositionCode}) — оставлен {existing.PositionCode}.";
|
||||
var combinedWarning = string.Join(" ", new[] { existing.Warning, conflictWarning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
result[index] = existing with { Warning = combinedWarning };
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user