4 Commits

Author SHA1 Message Date
gorbenko.pavel 738e1671c6 готово под локальное время
Tests / frontend-test (pull_request) Successful in 44s
Tests / backend-test (pull_request) Successful in 4m10s
2026-08-27 16:02:50 +05:00
gorbenko.pavel 31a0a8e9e3 Напоминания: убрать дубль 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 вместо прежнего молчаливого «И». Админка не
затронута — она это поле всегда отправляет.
2026-08-27 15:53:31 +05:00
gorbenko.pavel 81ed85d6ce стайлгайд 2026-08-27 15:23:45 +05:00
gorbenko.pavel 554f9b2528 правки 2026-08-27 15:21:34 +05:00
14 changed files with 331 additions and 115 deletions
+4
View File
@@ -0,0 +1,4 @@
# Стайл-гайд для Клода
- У `enum` первое значение (0) — всегда `None`.
- Сборка не должна выдавать ни одного warning.
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.Api.Controllers; namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary> /// <summary>
/// Просроченные шаги согласования и исполнения на текущий момент. /// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
/// появления ProbationEnding.
/// ///
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает /// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
/// Power Automate по расписанию (токен приложения), но и из админки его удобно /// Power Automate по расписанию (токен приложения), но и из админки его удобно
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
{ {
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(CancellationToken ct) => public async Task<IActionResult> List(
(await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult(); [FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
(await service.GetRemindersAsync(feed, ct)).ToActionResult();
} }
@@ -52,6 +52,7 @@ builder.Services.AddCors(options =>
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod()); .AllowAnyMethod());
}); });
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication(); builder.Services.AddApprovalRouteApplication();
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders")); builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
@@ -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);
@@ -0,0 +1,15 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(ReminderFeed, CancellationToken)"/>.
///
/// <see cref="None"/> («вид не указан») трактуется как <see cref="ApprovalOverdue"/>:
/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и
/// вызов без явного выбора обязан работать как раньше.
/// </summary>
public enum ReminderFeed
{
None = 0,
ApprovalOverdue = 1,
ProbationEnding = 2,
}
@@ -24,6 +24,7 @@ public sealed class ReminderService(
ISharePointClient sharePoint, ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry, ListPartitionRegistry partitionRegistry,
ITnsuPortalClient portalClient, ITnsuPortalClient portalClient,
TimeProvider timeProvider,
IOptions<RemindersOptions> options, IOptions<RemindersOptions> options,
ILogger<ReminderService> logger) ILogger<ReminderService> logger)
{ {
@@ -32,17 +33,30 @@ public sealed class ReminderService(
private const string ActiveStatusFilter = private const string ActiveStatusFilter =
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
/// <summary>Оба вида напоминаний разом — то, что реально отдаёт контроллер.</summary> /// <summary>
public async Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync( /// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
DateTimeOffset now, CancellationToken cancellationToken) /// это был единственный вид напоминаний, и без явного выбора им и остаётся.
/// </summary>
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(CancellationToken cancellationToken) =>
GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken);
/// <summary>
/// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей
/// стороны. Момент «сейчас» берётся здесь же, из <see cref="TimeProvider"/>, а не
/// пробрасывается вызывающей стороной — во всех проде это <c>TimeProvider.System</c>
/// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/
/// GetProbationEndingAsync по-прежнему принимают <c>now</c> явным параметром: так
/// их сценарии остаются детерминированными без своего TimeProvider на каждый тест.
/// </summary>
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
ReminderFeed feed, CancellationToken cancellationToken)
{ {
var overdue = await GetOverdueAsync(now, cancellationToken); var now = timeProvider.GetUtcNow();
if (overdue.IsFailed) return overdue; return feed switch
{
var probation = await GetProbationEndingAsync(now, cancellationToken); ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
if (probation.IsFailed) return probation; _ => GetOverdueAsync(now, cancellationToken),
};
return Result.Ok<IReadOnlyList<ReminderDto>>(overdue.Value.Concat(probation.Value).ToList());
} }
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync( public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
@@ -191,9 +205,17 @@ public sealed class ReminderService(
// ДО появления колонки (см. ApprovalRow) или если request-portal // ДО появления колонки (см. ApprovalRow) или если request-portal
// почему-то не проставил её при активации. Без даты старта SLA не // почему-то не проставил её при активации. Без даты старта SLA не
// от чего отсчитывать — пропускаем, а не подставляем сейчас. // от чего отсчитывать — пропускаем, а не подставляем сейчас.
//
// RequestTypeId — всегда, чтобы искать TicketId в правильном типе
// заявок. RequestUniqueCode — то, по чему заявку реально ищут в
// портале, добавляется, только если он уже есть (строка данных
// могла ещё не появиться или заявка старше появления колонки).
var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
? $"requestTypeId={type.Id}, code={code}"
: $"requestTypeId={type.Id}";
logger.LogWarning( logger.LogWarning(
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена", "Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
row.Id, row.TicketId); row.Id, row.TicketId, identifier);
continue; continue;
} }
@@ -290,6 +312,19 @@ public sealed class ReminderService(
return result; return result;
} }
/// <summary>
/// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
///
/// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
/// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
/// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
/// портале, и напоминание уходило бы на день раньше срока.
///
/// Константой, а не настройкой: смещение фиксировано для всей организации и не
/// меняется от окружения (перехода на летнее время нет).
/// </summary>
private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption = private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
new Dictionary<string, int> new Dictionary<string, int>
{ {
@@ -381,15 +416,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;
@@ -397,17 +437,26 @@ public sealed class ReminderService(
if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months)) if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего. return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
var probationEndsAt = hireDate.AddMonths(months); // Обе даты — в местном поясе (см. LocalOffset): и дату приёма, и «сегодня»
var daysLeft = (probationEndsAt.Date - now.Date).Days; // надо брать по тому же календарю, который видит человек в портале.
var hireDateLocal = hireDate.ToOffset(LocalOffset);
var probationEndsAt = hireDateLocal.AddMonths(months);
var daysLeft = (probationEndsAt.Date - now.ToOffset(LocalOffset).Date).Days;
if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null; if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null;
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);
@@ -433,26 +482,19 @@ 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, // Тоже в местном поясе — иначе в письме дата приёма была бы на сутки
// раньше той, от которой посчитан ProbationEndsAt рядом.
HireDate: hireDateLocal,
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,8 +31,23 @@ 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 параметром — их
// тесты ниже дают его напрямую. TimeProvider нужен только там, где ReminderService
// сам решает, «сейчас» сколько — то есть в GetRemindersAsync (см. её doc-комментарий).
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
private static readonly TimeProvider Clock = new FixedTimeProvider(Now);
private readonly ServiceProvider _services; private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService; private readonly RequestTypeService _requestTypeService;
@@ -124,6 +139,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
sharePoint.Object, sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(), _services.GetRequiredService<ListPartitionRegistry>(),
Mock.Of<ITnsuPortalClient>(), Mock.Of<ITnsuPortalClient>(),
Clock,
Options.Create(new RemindersOptions()), Options.Create(new RemindersOptions()),
NullLogger<ReminderService>.Instance); NullLogger<ReminderService>.Instance);
} }
@@ -145,6 +161,32 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
reminder.RequestId.ShouldBe(1); reminder.RequestId.ShouldBe(1);
} }
[Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")]
public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue()
{
var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetRemindersAsync(CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(24);
}
[Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")]
public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue()
{
var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
public async Task WithinSla_ReturnsNothing() public async Task WithinSla_ReturnsNothing()
{ {
@@ -317,7 +359,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
sharePoint.Object, sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(), _services.GetRequiredService<ListPartitionRegistry>(),
portalClient.Object, portalClient.Object,
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }), Clock,
Options.Create(new RemindersOptions
{
RecruitingHirePageUrl = pageUrl,
ProbationReminderDaysBeforeEnd = daysBeforeEnd,
ProbationReminderUrl = ProbationReminderUrl
}),
NullLogger<ReminderService>.Instance); NullLogger<ReminderService>.Instance);
} }
@@ -356,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 заполнен) — напоминания нет")]
@@ -471,6 +522,30 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
result.Value.ShouldBeEmpty(); result.Value.ShouldBeEmpty();
} }
// Портал пишет дату приёма как МЕСТНУЮ полночь, переведённую в UTC, поэтому в
// списке она лежит как 19:00Z предыдущего дня. Здесь 2026-06-14T19:00Z — это
// 15 июня по местному календарю (см. LocalOffset в ReminderService): срок
// истекает 15 августа, до него от Now (12 августа) ровно 3 дня.
// Если считать по UTC, дата приёма превратилась бы в 14 июня, срок — в 14
// августа, и осталось бы 2 дня: напоминание не построилось бы вовсе.
[Fact(DisplayName = "Дата приёма считается по местному календарю, а не по UTC")]
public async Task HireDate_IsReadInLocalTimeZone()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation timezone");
var rows = new[] { HireDataRow(1, "Закрыта", "Тимуров Тимур Тимурович", "2026-06-14T19:00:00Z", "2 месяца") };
var portalClient = BuildPortalClientMock(
"Тимуров Тимур Тимурович", dismissalDate: null, candidateEmail: "timurov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.DaysUntilProbationEnds.ShouldBe(3);
// И дата приёма, и конец срока отдаются в том же местном календаре.
reminder.HireDate.Date.ShouldBe(new DateTime(2026, 6, 15));
reminder.ProbationEndsAt.Date.ShouldBe(new DateTime(2026, 8, 15));
}
// Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption // Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption
// в ReminderService), поэтому заявка, способная дать напоминание сегодня, // в ReminderService), поэтому заявка, способная дать напоминание сегодня,
// физически не может лежать раньше предпоследней партиции. Три партиции: // физически не может лежать раньше предпоследней партиции. Три партиции:
@@ -519,8 +594,8 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz"); fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = new ReminderService( var service = new ReminderService(
db, sharePoint.Object, partitionRegistry, portalClient.Object, 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