Обходной лист #12

Merged
gorbenko.pavel merged 11 commits from clearance-form into main 2026-08-17 05:43:52 +00:00
2 changed files with 51 additions and 33 deletions
Showing only changes of commit 2eb63b5077 - Show all commits
@@ -23,18 +23,10 @@ public sealed record ResolvedRouteStepDto(
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
/// <param name="Warning">
/// Предупреждение уровня всего резолва (не привязано к конкретному шагу) — например,
/// в маршруте есть шаг с условием по полю IsManager, а в заявке не передано значение
/// поля Position, из которого IsManager вычисляется. Это не ошибка (резолв всё равно
/// отработал, IsManager просто посчитался как «false»), но может означать, что
/// вызывающая сторона забыла передать поле, и шаг из-за этого не появился, хотя должен был.
/// </param>
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
int RouteId, int RouteId,
bool RequiresControl, bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps, IReadOnlyList<ResolvedRouteStepDto> Steps);
string? Warning = null);
/// <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);
@@ -171,36 +171,32 @@ public sealed class TemplateRouteService(
// того, кто отправил заявку). Это свободный текст, поэтому определяем // того, кто отправил заявку). Это свободный текст, поэтому определяем
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке — // «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
// считаем «нет» (а не подставляем должность инициатора как фолбэк). // считаем «нет» (а не подставляем должность инициатора как фолбэк).
fieldValues.TryGetValue("Position", out var formPosition); fieldValues.TryGetValue(PositionField, out var formPosition);
fieldValues["IsManager"] = IsManagerPosition(formPosition) ? "true" : "false"; fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
// Предупреждение уровня резолва, а не шага: условие по IsManager есть в маршруте // Должность в заявке не передана вовсе — условие по IsManager вычислить не из
// (хотя бы на одном шаге, независимо от статуса — фильтр по s.Status ниже сузил бы // чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
// проверку до конкретной фазы, а «забыли передать Position» это не про фазу), но // лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
// Position в заявке не пришёл вовсе. Если поле пришло, но пустой строкой или не // ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
// похоже на руководящую должность — это осознанный «false», предупреждать не о чем. // руководящую, — это осознанный «нет», предупреждать не о чем.
string? generalWarning = null; var positionMissing = string.IsNullOrWhiteSpace(formPosition);
if (string.IsNullOrWhiteSpace(formPosition) &&
route.Steps.Any(s => string.Equals(s.FieldName, "IsManager", StringComparison.OrdinalIgnoreCase)))
{
generalWarning = "В маршруте есть шаг с условием по полю «IsManager», но в заявке не передано значение " +
"поля «Position» (должность) — условие считается невыполненным.";
}
var matchingSteps = route.Steps var matchingSteps = route.Steps
.Where(s => s.Status == request.Status) .Where(s => s.Status == request.Status)
.Where(s => MatchesCondition(s, fieldValues)) .Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
.OrderBy(s => s.OrderNo) .OrderBy(s => s.OrderNo)
.ToList(); .ToList();
var steps = new List<ResolvedRouteStepDto>(); var steps = new List<ResolvedRouteStepDto>();
foreach (var step in matchingSteps) foreach (var step in matchingSteps)
steps.Add(await ResolveStepAsync( steps.Add(await ResolveStepAsync(
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken)); step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
cancellationToken));
var collapsedSteps = CollapseByExecutorEmail(steps); var collapsedSteps = CollapseByExecutorEmail(steps);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps, generalWarning)); return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
} }
/// <summary> /// <summary>
@@ -238,9 +234,16 @@ public sealed class TemplateRouteService(
x.ByProject == byProject, x.ByProject == byProject,
cancellationToken); cancellationToken);
/// <param name="conditionWarning">
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
/// </param>
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, Guid? departmentId, RequestType requestType, IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
string? conditionWarning,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
string? resolvedEmail; string? resolvedEmail;
@@ -311,13 +314,16 @@ public sealed class TemplateRouteService(
// работает именно так, как задумано — значение шага перекрывает типовое, // работает именно так, как задумано — значение шага перекрывает типовое,
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым // и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours). // (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
.Where(w => !string.IsNullOrWhiteSpace(w)));
return new ResolvedRouteStepDto( return new ResolvedRouteStepDto(
step.Id, step.Id,
step.OrderNo, step.OrderNo,
resolvedEmail, resolvedEmail,
resolvedPosition, resolvedPosition,
step.AllowReassignToOtherApprovers, step.AllowReassignToOtherApprovers,
warning, combinedWarning.Length == 0 ? null : combinedWarning,
step.SlaHours ?? requestType.SlaHours, step.SlaHours ?? requestType.SlaHours,
step.AskOverdueReason ?? requestType.AskOverdueReason); step.AskOverdueReason ?? requestType.AskOverdueReason);
} }
@@ -353,13 +359,33 @@ public sealed class TemplateRouteService(
private static bool TryParseNumber(string? value, out double number) => private static bool TryParseNumber(string? value, out double number) =>
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
/// <summary>Поле заявки, из которого вычисляется <see cref="ManagerConditionField"/>.</summary>
private const string PositionField = "Position";
/// <summary>Синтетическое поле условия шага — вычисляется, а не приходит из заявки.</summary>
private const string ManagerConditionField = "IsManager";
private const string PositionMissingWarning =
"В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " +
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
/// <summary> /// <summary>
/// Должность в справочнике api.tnsu.kz — свободный текст без структуры (нет /// Условие шага завязано на IsManager, но вычислить его не из чего: должность
/// отдельного флага «это руководитель»), поэтому определяем это по ключевым /// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
/// словам в названии должности. Список подобран по фактическим должностям /// предупреждением — см. ResolveAsync.
/// в справочнике — Начальник/Руководитель/Директор/Заместитель/Главный/ /// </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);
/// <summary>
/// Должность — свободный текст без структуры (нет отдельного флага «это
/// руководитель»), поэтому определяем это по ключевым словам в её названии.
/// Список подобран по фактическим должностям в справочнике api.tnsu.kz —
/// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом.
/// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший
/// специалист, а не должность с подчинёнными.
/// </summary> /// </summary>
private static readonly string[] ManagerPositionKeywords = private static readonly string[] ManagerPositionKeywords =
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"]; ["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];