Напоминания: убрать дубль ManagerEmail, ссылка из настроек; None первым во всех enum

Напоминания:
- ProbationEndingReminderDto: ManagerEmail убран — это был унаследованный
  ReceiverEmail под вторым именем.
- Ссылка для ProbationEnding больше не ведёт на саму заявку на приём (она уже
  закрыта, решать по ней нечего) — берётся из новой настройки
  RemindersOptions.ProbationReminderUrl и ведёт в раздел, где заводят новую
  заявку. Подстановок в ней нет: адрес конкретной формы в портал не передать,
  rptype без rpid/rpsource игнорируется (см. parseRoute в request-portal).
  Заодно ушли BuildProbationUrl и ненужные больше параметры type/listId.

Стайлгайд (CLAUDE.md) — у enum первое значение None:
- ReminderFeed: None=0 трактуется как ApprovalOverdue, чтобы вызов без явного
  выбора работал как раньше.
- EmploymentStatus: заодно инвертирован switch в ReminderService — раньше
  «продолжить» было веткой по умолчанию, и новый None молча означал бы
  «сотрудник работает». Теперь по умолчанию пропуск с предупреждением.
- ConditionsLogic: None — НЕвалидное значение, отвергается валидаторами шага
  (в том числе новым PatchTemplateRouteStepRequestValidator, его не было вовсе).
  Поэтому у ConditionsLogic в Create/Update-запросах убрано значение по
  умолчанию: System.Text.Json в .NET 10 уважает дефолты параметров
  конструктора, и пропущенное поле молча означало «И». Теперь пропущенное поле
  даёт None и упирается в валидатор.

ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ КОНТРАКТА: клиент, не присылающий conditionsLogic в
POST/PUT шага, получит 400 вместо прежнего молчаливого «И». Админка не
затронута — она это поле всегда отправляет.
This commit is contained in:
gorbenko.pavel
2026-08-27 15:53:31 +05:00
parent 81ed85d6ce
commit 31a0a8e9e3
11 changed files with 192 additions and 102 deletions
@@ -4,14 +4,21 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
internal enum EmploymentStatus internal enum EmploymentStatus
{ {
/// <summary>
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
/// </summary>
None = 0,
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary> /// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
NotFound, NotFound = 1,
/// <summary>Работает хотя бы в одной компании.</summary> /// <summary>Работает хотя бы в одной компании.</summary>
Employed, Employed = 2,
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary> /// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
Dismissed, Dismissed = 3,
} }
/// <summary> /// <summary>
@@ -1,8 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="ManagerEmail"> /// <param name="ReceiverEmail">
/// То же значение, что и унаследованный ReceiverEmail, — email руководителя /// Email руководителя принимаемого сотрудника — кому адресовано напоминание.
/// принимаемого сотрудника, кому адресовано напоминание. /// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение
/// под вторым именем.
/// </param> /// </param>
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param> /// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param> /// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
@@ -11,7 +12,7 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="ProbationEndsAt">Дата окончания испытательного срока (HireDate + срок).</param> /// <param name="ProbationEndsAt">Дата окончания испытательного срока (HireDate + срок).</param>
/// <param name="DaysUntilProbationEnds">Сколько дней осталось до конца испытательного срока.</param> /// <param name="DaysUntilProbationEnds">Сколько дней осталось до конца испытательного срока.</param>
public sealed record ProbationEndingReminderDto( public sealed record ProbationEndingReminderDto(
string ManagerEmail, string ReceiverEmail,
string CandidateEmail, string CandidateEmail,
string CandidateName, string CandidateName,
string? ProbationPeriod, string? ProbationPeriod,
@@ -20,4 +21,4 @@ public sealed record ProbationEndingReminderDto(
int DaysUntilProbationEnds, int DaysUntilProbationEnds,
string Url, string Url,
string? ReceiverName = null) string? ReceiverName = null)
: ReminderDto(ManagerEmail, ReceiverName, Url); : ReminderDto(ReceiverEmail, ReceiverName, Url);
@@ -1,16 +1,15 @@
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary> /// <summary>
/// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(DateTimeOffset, ReminderFeed, CancellationToken)"/>. /// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(ReminderFeed, CancellationToken)"/>.
/// ///
/// GetRemindersAsync изначально (до появления ProbationEnding) отдавал только /// <see cref="None"/> («вид не указан») трактуется как <see cref="ApprovalOverdue"/>:
/// просроченные шаги согласования — этот вид и остаётся значением по умолчанию /// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и
/// (0), чтобы старые потребители (Power Automate), не указывающие feed явно, /// вызов без явного выбора обязан работать как раньше.
/// продолжали получать ровно то же, что и раньше.
/// </summary> /// </summary>
public enum ReminderFeed public enum ReminderFeed
{ {
None = 0, None = 0,
ApprovalOverdue = 1, ApprovalOverdue = 1,
ProbationEnding = 2 ProbationEnding = 2,
} }
@@ -403,15 +403,20 @@ public sealed class ReminderService(
var reminders = new List<ReminderDto>(); var reminders = new List<ReminderDto>();
foreach (var row in rows) foreach (var row in rows)
{ {
var reminder = await TryBuildProbationReminderAsync(type, row, listId, directory, now, cancellationToken); var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken);
if (reminder is not null) reminders.Add(reminder); if (reminder is not null) reminders.Add(reminder);
} }
return reminders; return reminders;
} }
/// <summary>
/// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний —
/// константа из настроек (см. ProbationReminderUrl), а поля самой заявки
/// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят.
/// </summary>
private async Task<ReminderDto?> TryBuildProbationReminderAsync( private async Task<ReminderDto?> TryBuildProbationReminderAsync(
RequestType type, HireRow row, string listId, EmployeeDirectorySnapshot directory, HireRow row, EmployeeDirectorySnapshot directory,
DateTimeOffset now, CancellationToken cancellationToken) DateTimeOffset now, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null; if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null;
@@ -425,11 +430,17 @@ public sealed class ReminderService(
var candidateName = row.CandidateName.Trim(); var candidateName = row.CandidateName.Trim();
// Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default:
// так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None,
// если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не
// молча считается «работает».
switch (directory.EmploymentOf(candidateName)) switch (directory.EmploymentOf(candidateName))
{ {
case EmploymentStatus.Employed:
break;
case EmploymentStatus.Dismissed: case EmploymentStatus.Dismissed:
return null; // уже уволен — напоминать не о чем. return null; // уже уволен — напоминать не о чем.
case EmploymentStatus.NotFound: default:
logger.LogWarning( logger.LogWarning(
"Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено", "Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено",
row.Id, candidateName); row.Id, candidateName);
@@ -455,26 +466,17 @@ public sealed class ReminderService(
} }
return new ProbationEndingReminderDto( return new ProbationEndingReminderDto(
ManagerEmail: managerEmail, ReceiverEmail: managerEmail,
CandidateEmail: candidateEmail, CandidateEmail: candidateEmail,
CandidateName: candidateName, CandidateName: candidateName,
ProbationPeriod: row.ProbationPeriod, ProbationPeriod: row.ProbationPeriod,
HireDate: hireDate, HireDate: hireDate,
ProbationEndsAt: probationEndsAt, ProbationEndsAt: probationEndsAt,
DaysUntilProbationEnds: daysLeft, DaysUntilProbationEnds: daysLeft,
Url: BuildProbationUrl(type, row, listId), // Не ссылка на саму заявку на приём (она уже закрыта, решать по ней
// нечего) — константный адрес раздела, где руководитель заводит новую
// заявку по итогам испытательного срока. См. ProbationReminderUrl.
Url: options.Value.ProbationReminderUrl,
ReceiverName: directory.NameByEmail(managerEmail)); ReceiverName: directory.NameByEmail(managerEmail));
} }
/// <summary>
/// Ссылка на саму заявку (не на шаг согласования — этого вида напоминаний
/// понятие "фаза" не касается), в том же формате, что и BuildUrl.
/// </summary>
private string BuildProbationUrl(RequestType type, HireRow row, string listId) =>
options.Value.RequestUrlTemplate
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
.Replace("{pageUrl}", type.PageUrl)
.Replace("{requestId}", row.Id.ToString())
.Replace("{listId}", Uri.EscapeDataString(listId))
.Replace("{nav}", "approvals");
} }
@@ -34,4 +34,19 @@ public sealed class RemindersOptions
/// <summary>За сколько дней до конца испытательного срока присылать напоминание.</summary> /// <summary>За сколько дней до конца испытательного срока присылать напоминание.</summary>
public int ProbationReminderDaysBeforeEnd { get; set; } = 3; public int ProbationReminderDaysBeforeEnd { get; set; } = 3;
/// <summary>
/// Куда ведёт ссылка в напоминании об окончании испытательного срока — на
/// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от
/// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение
/// по итогам испытательного срока), а не открыть уже закрытую старую.
///
/// Поэтому ссылка константная, без подстановок: адрес конкретной формы в
/// портал вообще не передать — параметр rptype без rpid/rpsource
/// игнорируется, и маршрут сваливается в раздел «Подать заявку» (см.
/// parseRoute в request-portal/shared/routing.ts). Отдельная настройка, а не
/// хардкод, — чтобы адрес правился конфигурацией.
/// </summary>
public string ProbationReminderUrl { get; set; } =
"https://tnsukz.sharepoint.com/sites/requests?rpnav=new";
} }
@@ -35,7 +35,7 @@ public sealed record CreateTemplateRouteStepRequest(
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic = ConditionsLogic.And, ConditionsLogic ConditionsLogic,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -48,7 +48,7 @@ public sealed record UpdateTemplateRouteStepRequest(
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
IReadOnlyList<RouteStepConditionDto>? Conditions, IReadOnlyList<RouteStepConditionDto>? Conditions,
ConditionsLogic ConditionsLogic = ConditionsLogic.And, ConditionsLogic ConditionsLogic,
int? SlaHours = null, int? SlaHours = null,
bool? AskOverdueReason = null); bool? AskOverdueReason = null);
@@ -8,6 +8,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
public CreateTemplateRouteStepRequestValidator() public CreateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x.ConditionsLogic)
.NotEqual(ConditionsLogic.None)
.WithMessage("Логика объединения условий обязательна: And или Or.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -28,6 +31,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
public UpdateTemplateRouteStepRequestValidator() public UpdateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution."); RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
RuleFor(x => x.ConditionsLogic)
.NotEqual(ConditionsLogic.None)
.WithMessage("Логика объединения условий обязательна: And или Or.");
RuleFor(x => x) RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -43,6 +49,35 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
} }
} }
/// <summary>
/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно:
/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО
/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest).
/// </summary>
public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator<PatchTemplateRouteStepRequest>
{
public PatchTemplateRouteStepRequestValidator()
{
When(x => x.Status is not null, () =>
RuleFor(x => x.Status)
.NotEqual(RouteStepStatus.None)
.WithMessage("Статус обязателен: Approval или Execution."));
When(x => x.ConditionsLogic is not null, () =>
RuleFor(x => x.ConditionsLogic)
.NotEqual(ConditionsLogic.None)
.WithMessage("Логика объединения условий обязательна: And или Or."));
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
.WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
// ноль допустим — это осознанное «напоминать сразу».
RuleFor(x => x.SlaHours)
.GreaterThanOrEqualTo(0)
.When(x => x.SlaHours is not null)
.WithMessage("SLA в часах не может быть отрицательным.");
}
}
internal static class TemplateRouteStepFields internal static class TemplateRouteStepFields
{ {
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) => public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
@@ -6,9 +6,21 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
/// </summary> /// </summary>
public enum ConditionsLogic public enum ConditionsLogic
{ {
/// <summary>
/// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И».
/// Существует только как значение по умолчанию (default(ConditionsLogic)):
/// шаг с такой логикой не должен ни создаваться, ни сохраняться.
///
/// Именно поэтому у самой сущности и у запросов на создание/обновление
/// значение по умолчанию задано явно — <c>ConditionsLogic.And</c>
/// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest):
/// пропущенное в запросе поле по-прежнему означает «И», как и раньше.
/// </summary>
None = 0,
/// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary> /// <summary>Шаг появляется, только если выполняются ВСЕ условия.</summary>
And, And = 1,
/// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary> /// <summary>Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.</summary>
Or Or = 2
} }
@@ -31,6 +31,11 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
private const string DataListTitle = "Заявка тест — данные"; private const string DataListTitle = "Заявка тест — данные";
private const string ApprovalListTitle = "Заявка тест — согласование"; private const string ApprovalListTitle = "Заявка тест — согласование";
// Своё значение (не то, что по умолчанию в RemindersOptions) — так тест
// проверяет, что ссылка действительно берётся из настроек, а не совпадает
// с ожидаемой случайно.
private const string ProbationReminderUrl = "https://portal.test/новая-заявка";
private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero);
// GetOverdueAsync/GetProbationEndingAsync по-прежнему берут now параметром — их // GetOverdueAsync/GetProbationEndingAsync по-прежнему берут now параметром — их
@@ -355,7 +360,12 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
_services.GetRequiredService<ListPartitionRegistry>(), _services.GetRequiredService<ListPartitionRegistry>(),
portalClient.Object, portalClient.Object,
Clock, Clock,
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }), Options.Create(new RemindersOptions
{
RecruitingHirePageUrl = pageUrl,
ProbationReminderDaysBeforeEnd = daysBeforeEnd,
ProbationReminderUrl = ProbationReminderUrl
}),
NullLogger<ReminderService>.Instance); NullLogger<ReminderService>.Instance);
} }
@@ -394,10 +404,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
result.IsSuccess.ShouldBeTrue(); result.IsSuccess.ShouldBeTrue();
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>(); var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); reminder.ReceiverEmail.ShouldBe("manager@tnsu.kz");
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
reminder.DaysUntilProbationEnds.ShouldBe(3); reminder.DaysUntilProbationEnds.ShouldBe(3);
// Ссылка ведёт не на саму заявку на приём, а на раздел, где заводят новую
// (константа из настроек, см. RemindersOptions.ProbationReminderUrl).
reminder.Url.ShouldBe(ProbationReminderUrl);
} }
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")] [Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
@@ -558,7 +571,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var service = new ReminderService( var service = new ReminderService(
db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock, db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock,
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }), Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderUrl = ProbationReminderUrl }),
NullLogger<ReminderService>.Instance); NullLogger<ReminderService>.Instance);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
@@ -169,7 +169,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -206,7 +206,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest( new CreateTemplateRouteStepRequest(
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, 1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And,
SlaHours: stepSla, AskOverdueReason: stepAsk), SlaHours: stepSla, AskOverdueReason: stepAsk),
CancellationToken.None); CancellationToken.None);
@@ -285,15 +285,15 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -342,11 +342,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -377,7 +377,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — departmentId в запросе не передан // Action — departmentId в запросе не передан
@@ -419,7 +419,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -459,7 +459,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -488,7 +488,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -515,11 +515,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -546,7 +546,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — fieldValues не переданы вовсе // Action — fieldValues не переданы вовсе
@@ -571,7 +571,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }), new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -605,7 +605,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }), new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -672,7 +672,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -710,7 +710,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -749,7 +749,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -785,7 +785,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -832,7 +832,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -883,7 +883,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -949,7 +949,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -998,11 +998,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail, // Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail,
@@ -1036,7 +1036,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -1065,7 +1065,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -1094,7 +1094,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -43,7 +43,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -59,7 +59,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -71,14 +71,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
{ {
// Arrange // Arrange
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null); var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And);
var first = await _service.CreateAsync(routeId, request, CancellationToken.None); var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var duplicate = await _service.CreateAsync( var duplicate = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null), new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -153,14 +153,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief // Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -174,14 +174,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false); var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action // Action
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -197,11 +197,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var chief = await _service.CreateAsync( var chief = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
var directorTansu = await _service.CreateAsync( var directorTansu = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -219,11 +219,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -240,11 +240,11 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var small = await _service.CreateAsync( var small = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
var big = await _service.CreateAsync( var big = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -259,14 +259,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
var first = await _service.CreateAsync( var first = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
first.IsSuccess.ShouldBeTrue(); first.IsSuccess.ShouldBeTrue();
// Action — то же условие, другой статус // Action — то же условие, другой статус
var second = await _service.CreateAsync( var second = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -280,14 +280,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false); var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId) // Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null), new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -303,7 +303,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -321,7 +321,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -341,7 +341,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -359,7 +359,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -374,14 +374,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -398,7 +398,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step", byProject: false); var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -422,7 +422,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false); var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -443,7 +443,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false); var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -457,6 +457,12 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
patched.IsFailed.ShouldBeTrue(); patched.IsFailed.ShouldBeTrue();
} }
// ConditionsLogic здесь всюду передаётся явно, потому что он обязателен: у
// Create/Update-запроса значения по умолчанию нет вовсе, а пропущенное в JSON
// поле превращается в ConditionsLogic.None и отвергается валидатором на слое
// API (TemplateRouteStepValidators + ValidationActionFilter). Отдельным тестом
// это не покрыто — сервис валидаторы не вызывает.
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")] [Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled() public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
{ {
@@ -464,7 +470,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false); var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -489,7 +495,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }), new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -505,7 +511,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -521,7 +527,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -539,7 +545,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests"); var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -560,7 +566,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -581,16 +587,16 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false); var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
await _service.CreateAsync( await _service.CreateAsync(
routeBId, routeBId,
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null), new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Другой исполнитель — не должен попасть в выдачу. // Другой исполнитель — не должен попасть в выдачу.
await _service.CreateAsync( await _service.CreateAsync(
routeAId, routeAId,
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null), new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
CancellationToken.None); CancellationToken.None);
// Action // Action