Обходной лист (#12)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
@@ -163,16 +163,42 @@ public sealed class TemplateRouteService(
|
||||
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Синтетическое поле условия — переопределяет то, что мог прислать вызывающий
|
||||
// код под тем же именем. Считаем НЕ по должности инициатора из справочника
|
||||
// api.tnsu.kz (employee.Position — она у самого сотрудника чаще всего не
|
||||
// «руководитель»), а по полю Position, заполненному В САМОЙ АНКЕТЕ (например,
|
||||
// «Должность» в Обходном листе — там указывают должность увольняемого, а не
|
||||
// того, кто отправил заявку). Это свободный текст, поэтому определяем
|
||||
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
|
||||
// считаем «нет» (а не подставляем должность инициатора как фолбэк).
|
||||
fieldValues.TryGetValue(PositionField, out var formPosition);
|
||||
fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
|
||||
|
||||
// Должность в заявке не передана вовсе — условие по IsManager вычислить не из
|
||||
// чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
|
||||
// лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
|
||||
// ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
|
||||
// руководящую, — это осознанный «нет», предупреждать не о чем.
|
||||
var positionMissing = string.IsNullOrWhiteSpace(formPosition);
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues))
|
||||
.Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.ToList();
|
||||
|
||||
var steps = new List<ResolvedRouteStepDto>();
|
||||
foreach (var step in matchingSteps)
|
||||
steps.Add(await ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken));
|
||||
// Шаги резолвятся ПАРАЛЛЕЛЬНО, а не по очереди: каждый шаг — это отдельные
|
||||
// походы в api.tnsu.kz (см. ResolveStepAsync), и на длинном маршруте
|
||||
// (десяток+ шагов) последовательные await один за другим складываются в
|
||||
// заметную задержку. Task.WhenAll сохраняет порядок результатов (массив
|
||||
// соответствует порядку задач, а не порядку завершения), поэтому
|
||||
// OrderBy(OrderNo) выше и CollapseByExecutorEmail ниже (ей важно, ЧЕЙ
|
||||
// дубликат считается первым) продолжают работать так же, как при
|
||||
// последовательном foreach.
|
||||
var steps = await Task.WhenAll(matchingSteps.Select(step => ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken)));
|
||||
|
||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||
|
||||
@@ -214,9 +240,16 @@ public sealed class TemplateRouteService(
|
||||
x.ByProject == byProject,
|
||||
cancellationToken);
|
||||
|
||||
/// <param name="conditionWarning">
|
||||
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
string? conditionWarning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? resolvedEmail;
|
||||
@@ -264,6 +297,7 @@ public sealed class TemplateRouteService(
|
||||
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||
HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail,
|
||||
_ => null
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(resolvedEmail))
|
||||
@@ -286,13 +320,16 @@ public sealed class TemplateRouteService(
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 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,
|
||||
warning,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason);
|
||||
}
|
||||
@@ -328,6 +365,41 @@ public sealed class TemplateRouteService(
|
||||
private static bool TryParseNumber(string? value, out double 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>
|
||||
/// Условие шага завязано на 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>
|
||||
private static readonly string[] ManagerPositionKeywords =
|
||||
["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"];
|
||||
|
||||
private static bool IsManagerPosition(string? position) =>
|
||||
!string.IsNullOrWhiteSpace(position) &&
|
||||
ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Reference in New Issue
Block a user