Расширение reminders (#19)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/19
This commit was merged in pull request #19.
This commit is contained in:
@@ -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.GetOverdueAsync(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"));
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="Email">
|
||||||
|
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
|
||||||
|
/// именем ради обратной совместимости с потребителями, читавшими это поле до
|
||||||
|
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HoursOverdue">
|
||||||
|
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||||
|
/// Выходные не учитываются — считаются календарные часы.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AskOverdueReason">
|
||||||
|
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||||
|
/// наследованием (шаг перекрывает тип заявки).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
|
||||||
|
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
|
||||||
|
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
|
||||||
|
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
||||||
|
/// <param name="RequestUniqueCode">
|
||||||
|
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||||
|
/// заявки его нет или строка данных не найдена.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ApprovalOverdueReminderDto(
|
||||||
|
string Email,
|
||||||
|
int HoursOverdue,
|
||||||
|
bool AskOverdueReason,
|
||||||
|
int RequestTypeId,
|
||||||
|
string RequestTypeName,
|
||||||
|
int RequestId,
|
||||||
|
string Status,
|
||||||
|
string? RequestUniqueCode,
|
||||||
|
string Url,
|
||||||
|
string? ReceiverName = null)
|
||||||
|
: ReminderDto(Email, ReceiverName, Url);
|
||||||
@@ -15,6 +15,7 @@ internal sealed record ApprovalRow(
|
|||||||
string Phase,
|
string Phase,
|
||||||
DateTimeOffset? Date,
|
DateTimeOffset? Date,
|
||||||
string? ApproverEmail,
|
string? ApproverEmail,
|
||||||
|
string? ApproverName,
|
||||||
int? SlaHours,
|
int? SlaHours,
|
||||||
bool? AskOverdueReason)
|
bool? AskOverdueReason)
|
||||||
{
|
{
|
||||||
@@ -23,9 +24,9 @@ internal sealed record ApprovalRow(
|
|||||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||||
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||||
|
|
||||||
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||||
? GetString(a, "EMail")
|
? a
|
||||||
: null;
|
: (JsonElement?)null;
|
||||||
|
|
||||||
return new ApprovalRow(
|
return new ApprovalRow(
|
||||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||||
@@ -35,7 +36,10 @@ internal sealed record ApprovalRow(
|
|||||||
// трактуем как «Согласование» (так же поступает request-portal).
|
// трактуем как «Согласование» (так же поступает request-portal).
|
||||||
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||||
TryGetDate(item, "Date"),
|
TryGetDate(item, "Date"),
|
||||||
approver,
|
approverObject is { } ao ? GetString(ao, "EMail") : null,
|
||||||
|
// Title у персонального поля — не всегда заполнен (например, у
|
||||||
|
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
|
||||||
|
approverObject is { } an ? GetString(an, "Title") : null,
|
||||||
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
|
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
internal enum EmploymentStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
|
||||||
|
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
|
||||||
|
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
||||||
|
NotFound = 1,
|
||||||
|
|
||||||
|
/// <summary>Работает хотя бы в одной компании.</summary>
|
||||||
|
Employed = 2,
|
||||||
|
|
||||||
|
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
||||||
|
Dismissed = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть
|
||||||
|
/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны
|
||||||
|
/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет
|
||||||
|
/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат
|
||||||
|
/// записан именно ФИО (колонка CandidateName) — временное решение, см.
|
||||||
|
/// <see cref="ITnsuPortalClient.GetAllEmployeesAsync"/>.
|
||||||
|
///
|
||||||
|
/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда
|
||||||
|
/// это несколько записей одного и того же человека), но с тем же успехом могут
|
||||||
|
/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя,
|
||||||
|
/// поэтому все совпадения трактуются как записи одного человека.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EmployeeDirectorySnapshot
|
||||||
|
{
|
||||||
|
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
|
||||||
|
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||||||
|
|
||||||
|
public EmployeeDirectorySnapshot(
|
||||||
|
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
||||||
|
{
|
||||||
|
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
||||||
|
_contactsByFio = contacts.ToLookup(e => Normalize(e.Fio));
|
||||||
|
|
||||||
|
// Обратный индекс email → ФИО — нужен, чтобы подписать напоминание именем
|
||||||
|
// получателя, когда известен только его email (руководитель кандидата,
|
||||||
|
// см. GetManagerAsync). При дублях email в справочнике побеждает первая
|
||||||
|
// запись — этого достаточно для отображения имени, не для идентификации.
|
||||||
|
_fioByEmail = contacts
|
||||||
|
.Where(e => !string.IsNullOrWhiteSpace(e.Email))
|
||||||
|
.GroupBy(e => e.Email!.Trim().ToLowerInvariant())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Fio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его
|
||||||
|
/// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в
|
||||||
|
/// другой, продолжает работать — и напоминание ему по-прежнему нужно.
|
||||||
|
/// </summary>
|
||||||
|
public EmploymentStatus EmploymentOf(string fio)
|
||||||
|
{
|
||||||
|
var entries = _fullBaseByFio[Normalize(fio)].ToList();
|
||||||
|
if (entries.Count == 0) return EmploymentStatus.NotFound;
|
||||||
|
|
||||||
|
return entries.All(e => e.DismissalDate is not null)
|
||||||
|
? EmploymentStatus.Dismissed
|
||||||
|
: EmploymentStatus.Employed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Первый непустой email среди записей этого ФИО. У одного человека записей
|
||||||
|
/// несколько (по компании на каждую), а email у него один и тот же — но
|
||||||
|
/// заполнен он может быть не в каждой записи.
|
||||||
|
/// </summary>
|
||||||
|
public string? EmailOf(string fio) =>
|
||||||
|
_contactsByFio[Normalize(fio)]
|
||||||
|
.Select(e => e.Email)
|
||||||
|
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
||||||
|
|
||||||
|
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||||||
|
public string? NameByEmail(string email) =>
|
||||||
|
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
||||||
|
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
||||||
|
/// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All).
|
||||||
|
/// </summary>
|
||||||
|
private static string Normalize(string fio) =>
|
||||||
|
string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Строка списка «Прием на работу — данные», разобранная из ответа SharePoint.
|
||||||
|
/// Имена колонок — как в request-portal (provisioning.json формы RecruitingHire).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record HireRow(
|
||||||
|
int Id,
|
||||||
|
string Status,
|
||||||
|
string? CandidateName,
|
||||||
|
DateTimeOffset? HireDate,
|
||||||
|
string? ProbationPeriod,
|
||||||
|
string? RequestUniqueCode)
|
||||||
|
{
|
||||||
|
public static HireRow? TryParse(JsonElement item)
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) return null;
|
||||||
|
|
||||||
|
return new HireRow(
|
||||||
|
id,
|
||||||
|
GetString(item, "RequestStatus") ?? string.Empty,
|
||||||
|
GetString(item, "CandidateName"),
|
||||||
|
TryGetDate(item, "HireDate"),
|
||||||
|
GetString(item, "ProbationPeriod"),
|
||||||
|
GetString(item, "RequestUniqueCode"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
|
||||||
|
private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
|
||||||
|
e.TryGetProperty(name, out var v) &&
|
||||||
|
v.ValueKind == JsonValueKind.String &&
|
||||||
|
DateTimeOffset.TryParse(v.GetString(), out var parsed)
|
||||||
|
? parsed
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Статусы списка данных («… — данные», колонка RequestStatus) — считаем приём завершённым по ним.</summary>
|
||||||
|
internal static class HireRequestStatuses
|
||||||
|
{
|
||||||
|
public const string Approved = "Согласовано";
|
||||||
|
public const string Closed = "Закрыта";
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
/// <param name="ReceiverEmail">
|
||||||
|
/// Email руководителя принимаемого сотрудника — кому адресовано напоминание.
|
||||||
|
/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение
|
||||||
|
/// под вторым именем.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
|
||||||
|
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
|
||||||
|
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
|
||||||
|
/// <param name="HireDate">Дата приёма (колонка HireDate).</param>
|
||||||
|
/// <param name="ProbationEndsAt">Дата окончания испытательного срока (HireDate + срок).</param>
|
||||||
|
/// <param name="DaysUntilProbationEnds">Сколько дней осталось до конца испытательного срока.</param>
|
||||||
|
public sealed record ProbationEndingReminderDto(
|
||||||
|
string ReceiverEmail,
|
||||||
|
string CandidateEmail,
|
||||||
|
string CandidateName,
|
||||||
|
string? ProbationPeriod,
|
||||||
|
DateTimeOffset HireDate,
|
||||||
|
DateTimeOffset ProbationEndsAt,
|
||||||
|
int DaysUntilProbationEnds,
|
||||||
|
string Url,
|
||||||
|
string? ReceiverName = null)
|
||||||
|
: ReminderDto(ReceiverEmail, ReceiverName, Url);
|
||||||
@@ -1,27 +1,32 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
/// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
|
||||||
/// <param name="HoursOverdue">
|
/// <param name="ReceiverName">
|
||||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
|
||||||
/// Выходные не учитываются — считаются календарные часы.
|
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
|
/// <param name="Url">
|
||||||
/// <param name="AskOverdueReason">
|
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
|
||||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
/// потому что не константна: requestId и партиция списка (server-relative url,
|
||||||
/// наследованием (шаг перекрывает тип заявки).
|
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
|
||||||
|
/// дублировать эту логику снаружи незачем.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
/// <remarks>
|
||||||
/// <param name="RequestUniqueCode">
|
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
||||||
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
|
||||||
/// заявки его нет или строка данных не найдена.
|
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
|
||||||
/// </param>
|
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
|
||||||
public sealed record ReminderDto(
|
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
|
||||||
string Email,
|
///
|
||||||
int HoursOverdue,
|
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
|
||||||
string Url,
|
/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
|
||||||
bool AskOverdueReason,
|
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
|
||||||
int RequestTypeId,
|
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
|
||||||
string RequestTypeName,
|
/// со значением "ApprovalOverdue"/"ProbationEnding".
|
||||||
int RequestId,
|
/// </remarks>
|
||||||
string Status,
|
[JsonPolymorphic]
|
||||||
string? RequestUniqueCode);
|
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
|
||||||
|
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
|
||||||
|
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string 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,
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
|||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ public sealed class ReminderService(
|
|||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
ISharePointClient sharePoint,
|
ISharePointClient sharePoint,
|
||||||
ListPartitionRegistry partitionRegistry,
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
TimeProvider timeProvider,
|
||||||
IOptions<RemindersOptions> options,
|
IOptions<RemindersOptions> options,
|
||||||
ILogger<ReminderService> logger)
|
ILogger<ReminderService> logger)
|
||||||
{
|
{
|
||||||
@@ -30,6 +33,32 @@ 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>
|
||||||
|
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
|
||||||
|
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
|
||||||
|
/// </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 now = timeProvider.GetUtcNow();
|
||||||
|
return feed switch
|
||||||
|
{
|
||||||
|
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
|
||||||
|
_ => GetOverdueAsync(now, cancellationToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||||
DateTimeOffset now, CancellationToken cancellationToken)
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -176,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,16 +225,17 @@ public sealed class ReminderService(
|
|||||||
var deadline = activatedAt.AddHours(slaHours);
|
var deadline = activatedAt.AddHours(slaHours);
|
||||||
if (deadline >= now) continue;
|
if (deadline >= now) continue;
|
||||||
|
|
||||||
reminders.Add(new ReminderDto(
|
reminders.Add(new ApprovalOverdueReminderDto(
|
||||||
row.ApproverEmail!,
|
row.ApproverEmail!,
|
||||||
(int)Math.Floor((now - deadline).TotalHours),
|
(int)Math.Floor((now - deadline).TotalHours),
|
||||||
BuildUrl(type, row, listId),
|
|
||||||
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
||||||
type.Id,
|
type.Id,
|
||||||
type.Name,
|
type.Name,
|
||||||
row.TicketId,
|
row.TicketId,
|
||||||
row.Status,
|
row.Status,
|
||||||
uniqueCodes.GetValueOrDefault(row.TicketId)));
|
uniqueCodes.GetValueOrDefault(row.TicketId),
|
||||||
|
BuildUrl(type, row, listId),
|
||||||
|
row.ApproverName));
|
||||||
}
|
}
|
||||||
|
|
||||||
return reminders;
|
return reminders;
|
||||||
@@ -234,7 +272,7 @@ public sealed class ReminderService(
|
|||||||
|
|
||||||
private const string RowSelect =
|
private const string RowSelect =
|
||||||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||||||
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
"TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||||||
@@ -273,4 +311,190 @@ 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 =
|
||||||
|
new Dictionary<string, int>
|
||||||
|
{
|
||||||
|
["1 месяц"] = 1,
|
||||||
|
["2 месяца"] = 2,
|
||||||
|
["3 месяца"] = 3,
|
||||||
|
["6 месяцев"] = 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Кому скоро выходить из испытательного срока — по заявкам «Заявка на приём»
|
||||||
|
/// (recruiting-hire). В отличие от <see cref="GetOverdueAsync"/> НЕ завязано на
|
||||||
|
/// SlaHours типа заявки: это отдельный вид напоминаний со своим триггером
|
||||||
|
/// (дата приёма + испытательный срок), а не просрочка шага согласования.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetProbationEndingAsync(
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var type = await db.RequestTypes.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.PageUrl == options.Value.RecruitingHirePageUrl && x.ListId != null && x.SiteUrl != null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (type is null) return Result.Ok<IReadOnlyList<ReminderDto>>([]);
|
||||||
|
|
||||||
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
|
// Испытательный срок — не больше 6 месяцев (см. ProbationMonthsByOption),
|
||||||
|
// поэтому заявка, способная дать напоминание сегодня, обязана лежать в
|
||||||
|
// текущей партиции или в партиции прямо перед ней: более старая партиция
|
||||||
|
// физически не может содержать ещё актуальный испытательный срок. Читать
|
||||||
|
// весь архив, как это делает GetOverdueAsync (там заявка может висеть
|
||||||
|
// непринятой годами), здесь незачем — только лишняя нагрузка на SharePoint.
|
||||||
|
var lastArchived = archived.OrderByDescending(x => x.Number).FirstOrDefault();
|
||||||
|
var partitions = new List<(string ListId, int Number)> { (current.ListId, current.Number) };
|
||||||
|
if (lastArchived is not null) partitions.Add((lastArchived.ListId, lastArchived.Number));
|
||||||
|
|
||||||
|
// Обе базы сотрудников читаются по одному разу на весь обход партиций (а не
|
||||||
|
// на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
|
||||||
|
// сразу и дата увольнения (только в FullBase), и email (только в All).
|
||||||
|
var directory = new EmployeeDirectorySnapshot(
|
||||||
|
await portalClient.GetFullBaseAsync(cancellationToken),
|
||||||
|
await portalClient.GetAllEmployeesAsync(cancellationToken));
|
||||||
|
|
||||||
|
var reminders = new List<ReminderDto>();
|
||||||
|
foreach (var (listId, number) in partitions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reminders.AddRange(
|
||||||
|
await GetProbationEndingForPartitionAsync(type, listId, directory, now, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
e, "Напоминания об испытательном сроке по списку {ListId} (партиция №{Number}) не собраны",
|
||||||
|
listId, number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<ReminderDto>>(reminders);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string HireRowSelect =
|
||||||
|
"$select=Id,RequestStatus,CandidateName,HireDate,ProbationPeriod,RequestUniqueCode&$top=5000";
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<ReminderDto>> GetProbationEndingForPartitionAsync(
|
||||||
|
RequestType type, string listId, EmployeeDirectorySnapshot directory,
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
|
||||||
|
|
||||||
|
// HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две
|
||||||
|
// последних партиции (см. GetProbationEndingAsync): более старый приём уже
|
||||||
|
// не может дать напоминание, читать его строки незачем даже в пределах
|
||||||
|
// партиции, где они могут лежать вперемешку со свежими.
|
||||||
|
var since = now.AddMonths(-6);
|
||||||
|
var filter =
|
||||||
|
$"$filter=(RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}')" +
|
||||||
|
$" and HireDate ge datetime'{since:yyyy-MM-ddTHH:mm:ssZ}'";
|
||||||
|
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||||
|
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
|
||||||
|
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
|
|
||||||
|
var reminders = new List<ReminderDto>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken);
|
||||||
|
if (reminder is not null) reminders.Add(reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reminders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний —
|
||||||
|
/// константа из настроек (см. ProbationReminderUrl), а поля самой заявки
|
||||||
|
/// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ReminderDto?> TryBuildProbationReminderAsync(
|
||||||
|
HireRow row, EmployeeDirectorySnapshot directory,
|
||||||
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null;
|
||||||
|
|
||||||
|
if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
|
||||||
|
return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
|
||||||
|
|
||||||
|
// Обе даты — в местном поясе (см. LocalOffset): и дату приёма, и «сегодня»
|
||||||
|
// надо брать по тому же календарю, который видит человек в портале.
|
||||||
|
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;
|
||||||
|
|
||||||
|
var candidateName = row.CandidateName.Trim();
|
||||||
|
|
||||||
|
// Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default:
|
||||||
|
// так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None,
|
||||||
|
// если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не
|
||||||
|
// молча считается «работает».
|
||||||
|
switch (directory.EmploymentOf(candidateName))
|
||||||
|
{
|
||||||
|
case EmploymentStatus.Employed:
|
||||||
|
break;
|
||||||
|
case EmploymentStatus.Dismissed:
|
||||||
|
return null; // уже уволен — напоминать не о чем.
|
||||||
|
default:
|
||||||
|
logger.LogWarning(
|
||||||
|
"Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено",
|
||||||
|
row.Id, candidateName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateEmail = directory.EmailOf(candidateName);
|
||||||
|
if (string.IsNullOrWhiteSpace(candidateEmail))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Заявка на приём {RequestId}: у кандидата «{Fio}» не найден email в справочнике — напоминание пропущено",
|
||||||
|
row.Id, candidateName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var managerEmail = await portalClient.GetManagerAsync(candidateEmail, "chief", cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(managerEmail))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Заявка на приём {RequestId}: у кандидата «{Fio}» не найден руководитель — напоминание пропущено",
|
||||||
|
row.Id, candidateName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProbationEndingReminderDto(
|
||||||
|
ReceiverEmail: managerEmail,
|
||||||
|
CandidateEmail: candidateEmail,
|
||||||
|
CandidateName: candidateName,
|
||||||
|
ProbationPeriod: row.ProbationPeriod,
|
||||||
|
// Тоже в местном поясе — иначе в письме дата приёма была бы на сутки
|
||||||
|
// раньше той, от которой посчитан ProbationEndsAt рядом.
|
||||||
|
HireDate: hireDateLocal,
|
||||||
|
ProbationEndsAt: probationEndsAt,
|
||||||
|
DaysUntilProbationEnds: daysLeft,
|
||||||
|
// Не ссылка на саму заявку на приём (она уже закрыта, решать по ней
|
||||||
|
// нечего) — константный адрес раздела, где руководитель заводит новую
|
||||||
|
// заявку по итогам испытательного срока. См. ProbationReminderUrl.
|
||||||
|
Url: options.Value.ProbationReminderUrl,
|
||||||
|
ReceiverName: directory.NameByEmail(managerEmail));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,35 @@ public sealed class RemindersOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string RequestUrlTemplate { get; set; } =
|
public string RequestUrlTemplate { get; set; } =
|
||||||
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
|
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PageUrl типа заявки «Заявка на приём» (см. request-portal, forms/RecruitingHire/model.ts,
|
||||||
|
/// FORM_ID) — по нему ищется RequestType для напоминаний об окончании испытательного срока.
|
||||||
|
/// Не завязано на SlaHours: этот вид напоминаний не про просрочку шага согласования.
|
||||||
|
/// </summary>
|
||||||
|
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// За сколько дней до конца испытательного срока присылать напоминание — неделя,
|
||||||
|
/// чтобы у руководителя было время подготовить решение по итогам срока.
|
||||||
|
///
|
||||||
|
/// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до
|
||||||
|
/// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной.
|
||||||
|
/// </summary>
|
||||||
|
public int ProbationReminderDaysBeforeEnd { get; set; } = 7;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
|
||||||
|
|||||||
+35
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,21 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
|
|||||||
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
|
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
|
||||||
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
|
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
|
||||||
|
|
||||||
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||||
() => inner.GetManagerAsync(email, managerType, cancellationToken));
|
() => inner.GetManagerAsync(email, managerType, cancellationToken));
|
||||||
|
|
||||||
|
// Базы целиком (/Employee/All выше и /Employee/FullBase ниже) кешируем по
|
||||||
|
// фиксированному ключу — это не запросы «по чему-то», а единственные на весь
|
||||||
|
// процесс снимки справочника; TTL тот же (5 минут), что и у остальных
|
||||||
|
// обращений к api.tnsu.kz.
|
||||||
|
public Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("emp:fullbase", () => inner.GetFullBaseAsync(cancellationToken));
|
||||||
|
|
||||||
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"projexec:{projectId}",
|
$"projexec:{projectId}",
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
|
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employees = await GetAsync<List<EmployeeResponse>>("/Employee/All", cancellationToken);
|
||||||
|
if (employees is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return employees
|
||||||
|
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
|
||||||
|
.Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var manager = await GetAsync<ManagerResponse>(
|
var manager = await GetAsync<ManagerResponse>(
|
||||||
@@ -40,6 +52,18 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
return manager?.ManagerEmail;
|
return manager?.ManagerEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employees = await GetAsync<List<FullBaseResponse>>("/Employee/FullBase", cancellationToken);
|
||||||
|
if (employees is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return employees
|
||||||
|
.Where(e => !string.IsNullOrWhiteSpace(e.Fio))
|
||||||
|
.Select(e => new EmployeeFullBaseInfo(e.Id, e.Fio!, e.Company ?? string.Empty, e.DismissalDate))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
||||||
@@ -100,6 +124,12 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
private sealed record ManagerResponse(
|
private sealed record ManagerResponse(
|
||||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|
||||||
|
private sealed record FullBaseResponse(
|
||||||
|
[property: JsonPropertyName("id")] Guid? Id,
|
||||||
|
[property: JsonPropertyName("fio")] string? Fio,
|
||||||
|
[property: JsonPropertyName("company")] string? Company,
|
||||||
|
[property: JsonPropertyName("dismissalDate")] DateTimeOffset? DismissalDate);
|
||||||
|
|
||||||
private sealed record ProjectResponse(
|
private sealed record ProjectResponse(
|
||||||
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
||||||
[property: JsonPropertyName("planner")] string? Planner,
|
[property: JsonPropertyName("planner")] string? Planner,
|
||||||
|
|||||||
@@ -11,8 +11,24 @@ public interface ITnsuPortalClient
|
|||||||
|
|
||||||
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Весь справочник сотрудников с email — /Employee/All. Нужен там, где известно
|
||||||
|
/// только ФИО, а email ещё нет: точечного поиска по ФИО в api.tnsu.kz нет вовсе
|
||||||
|
/// (эндпоинта /Employee/ByFio не существует), поэтому приходится тянуть базу
|
||||||
|
/// целиком и искать в ней локально. ВРЕМЕННОЕ решение — как только на стороне
|
||||||
|
/// api.tnsu.kz появится поиск по ФИО, этот вызов заменится точечным.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Весь справочник сотрудников — /Employee/FullBase. Единственный источник, где
|
||||||
|
/// есть дата увольнения (DismissalDate): нужен, чтобы не напоминать про
|
||||||
|
/// испытательный срок сотрудника, который уже уволен.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
|
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
|
||||||
|
|||||||
@@ -17,3 +17,17 @@ public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
|||||||
|
|
||||||
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
|
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
|
||||||
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
|
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запись из /Employee/FullBase — весь справочник сотрудников, но БЕЗ email (в
|
||||||
|
/// отличие от /Employee/All). Нужна только ради DismissalDate: единственное место,
|
||||||
|
/// где отдаётся дата увольнения.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, DateTimeOffset? DismissalDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запись из /Employee/All — весь справочник сотрудников с email, но БЕЗ даты
|
||||||
|
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
||||||
|
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company);
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами
|
||||||
|
/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и
|
||||||
|
/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см.
|
||||||
|
/// ReminderDto) — так переход на новый контракт можно делать постепенно, не
|
||||||
|
/// ломая существующие Power Automate-флоу разом.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReminderDtoTests
|
||||||
|
{
|
||||||
|
[Fact(DisplayName = "ApprovalOverdueReminderDto: Email и унаследованный ReceiverEmail — одно и то же значение")]
|
||||||
|
public void ApprovalOverdue_EmailAndReceiverEmail_AreTheSameValue()
|
||||||
|
{
|
||||||
|
var dto = new ApprovalOverdueReminderDto(
|
||||||
|
Email: "approver@tnsu.kz",
|
||||||
|
HoursOverdue: 5,
|
||||||
|
AskOverdueReason: false,
|
||||||
|
RequestTypeId: 1,
|
||||||
|
RequestTypeName: "Тип",
|
||||||
|
RequestId: 10,
|
||||||
|
Status: "На согласовании",
|
||||||
|
RequestUniqueCode: "code",
|
||||||
|
Url: "https://tnsukz.sharepoint.com/sites/requests");
|
||||||
|
|
||||||
|
dto.Email.ShouldBe("approver@tnsu.kz");
|
||||||
|
dto.ReceiverEmail.ShouldBe(dto.Email);
|
||||||
|
}
|
||||||
|
}
|
||||||
+367
-6
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -9,6 +10,7 @@ using Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
@@ -29,8 +31,28 @@ 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/новая-заявка";
|
||||||
|
|
||||||
|
// Порог фиксируем в тестах СВОИМ значением, не полагаясь на дефолт из
|
||||||
|
// RemindersOptions: изменение продового порога (3 → 7 дней и далее) не должно
|
||||||
|
// ломать тесты, которые проверяют вовсе не его.
|
||||||
|
private const int ProbationDaysBeforeEnd = 3;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -53,6 +75,23 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
return listUrl;
|
return listUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageUrl отдельным параметром (а не /{name}, как в CreateTypeWithSlaAsync) —
|
||||||
|
// GetProbationEndingAsync ищет тип НЕ по SLA, а именно по PageUrl (см.
|
||||||
|
// RemindersOptions.RecruitingHirePageUrl), и у каждого теста здесь свой
|
||||||
|
// уникальный PageUrl, передаваемый в опции сервиса той же строкой: так тесты
|
||||||
|
// не видят чужие «recruiting-hire»-записи, случайно оставшиеся от других
|
||||||
|
// тестов в общей БД фикстуры.
|
||||||
|
private async Task<(string ListUrl, string PageUrl)> CreateHireTypeAsync(string name)
|
||||||
|
{
|
||||||
|
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
|
||||||
|
var pageUrl = $"/{name.Replace(" ", string.Empty)}";
|
||||||
|
await _requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
return (listUrl, pageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||||
|
|
||||||
private static object Row(
|
private static object Row(
|
||||||
@@ -104,6 +143,8 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
|
Mock.Of<ITnsuPortalClient>(),
|
||||||
|
Clock,
|
||||||
Options.Create(new RemindersOptions()),
|
Options.Create(new RemindersOptions()),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
@@ -119,12 +160,38 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.IsSuccess.ShouldBeTrue();
|
result.IsSuccess.ShouldBeTrue();
|
||||||
var reminder = result.Value.ShouldHaveSingleItem();
|
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>();
|
||||||
reminder.Email.ShouldBe("approver@tnsu.kz");
|
reminder.Email.ShouldBe("approver@tnsu.kz");
|
||||||
reminder.HoursOverdue.ShouldBe(24);
|
reminder.HoursOverdue.ShouldBe(24);
|
||||||
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()
|
||||||
{
|
{
|
||||||
@@ -153,7 +220,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
||||||
@@ -179,7 +246,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
|
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
|
||||||
@@ -199,7 +266,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
||||||
@@ -211,7 +278,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().AskOverdueReason.ShouldBeTrue();
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().AskOverdueReason.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
||||||
@@ -256,6 +323,300 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz");
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().Email.ShouldBe("approver@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object HireDataRow(
|
||||||
|
int id, string status, string? candidateName, string? hireDate, string? probationPeriod, string? code = null) =>
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
RequestStatus = status,
|
||||||
|
CandidateName = candidateName,
|
||||||
|
HireDate = hireDate,
|
||||||
|
ProbationPeriod = probationPeriod,
|
||||||
|
RequestUniqueCode = code
|
||||||
|
};
|
||||||
|
|
||||||
|
private ReminderService BuildProbationService(
|
||||||
|
string pageUrl, string listUrl, IReadOnlyList<object> rows, Mock<ITnsuPortalClient> portalClient,
|
||||||
|
int daysBeforeEnd = ProbationDaysBeforeEnd)
|
||||||
|
{
|
||||||
|
const string hireDataListTitle = "Прием на работу тест — данные";
|
||||||
|
|
||||||
|
var sharePoint = new Mock<ISharePointClient>();
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(hireDataListTitle);
|
||||||
|
|
||||||
|
// GetProbationEndingAsync читает только «Согласовано»/«Закрыта» — SharePoint
|
||||||
|
// фильтрует это сам ($filter в запросе), мок повторяет ту же фильтрацию.
|
||||||
|
var closedRows = rows
|
||||||
|
.Where(r => (string)r.GetType().GetProperty("RequestStatus")!.GetValue(r)! is "Согласовано" or "Закрыта")
|
||||||
|
.ToList();
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(
|
||||||
|
SiteUrl, hireDataListTitle, It.Is<string>(q => q.Contains("RequestStatus eq")), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(closedRows.Select(Json).ToList());
|
||||||
|
|
||||||
|
return new ReminderService(
|
||||||
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
|
sharePoint.Object,
|
||||||
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
|
portalClient.Object,
|
||||||
|
Clock,
|
||||||
|
Options.Create(new RemindersOptions
|
||||||
|
{
|
||||||
|
RecruitingHirePageUrl = pageUrl,
|
||||||
|
ProbationReminderDaysBeforeEnd = daysBeforeEnd,
|
||||||
|
ProbationReminderUrl = ProbationReminderUrl
|
||||||
|
}),
|
||||||
|
NullLogger<ReminderService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<ITnsuPortalClient> BuildPortalClientMock(
|
||||||
|
string candidateFio, DateTimeOffset? dismissalDate, string? candidateEmail, string? managerEmail)
|
||||||
|
{
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new EmployeeFullBaseInfo(Guid.NewGuid(), candidateFio, "ТОО TANSU", dismissalDate)]);
|
||||||
|
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), candidateFio, candidateEmail, "ТОО TANSU")]);
|
||||||
|
|
||||||
|
if (candidateEmail is not null)
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync(candidateEmail, "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(managerEmail);
|
||||||
|
|
||||||
|
return portalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "До конца испытательного срока 3 дня — напоминание с email кандидата и его руководителя")]
|
||||||
|
public async Task ProbationEndingIn3Days_BuildsReminder()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation due");
|
||||||
|
// probationEndsAt = Now.Date + 3 дня = 2026-08-15; "2 месяца" от 2026-06-15.
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Иванов Иван Иванович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Иванов Иван Иванович", dismissalDate: null, candidateEmail: "candidate@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
|
||||||
|
reminder.ReceiverEmail.ShouldBe("manager@tnsu.kz");
|
||||||
|
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
|
||||||
|
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
|
||||||
|
reminder.DaysUntilProbationEnds.ShouldBe(3);
|
||||||
|
// Ссылка ведёт не на саму заявку на приём, а на раздел, где заводят новую
|
||||||
|
// (константа из настроек, см. RemindersOptions.ProbationReminderUrl).
|
||||||
|
reminder.Url.ShouldBe(ProbationReminderUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
|
||||||
|
public async Task DismissedCandidate_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation dismissed");
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Петров Пётр Петрович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Петров Пётр Петрович", dismissalDate: Now.AddDays(-1), candidateEmail: "petrov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сотрудник холдинга может числиться в нескольких компаниях сразу (см.
|
||||||
|
// /Employee/FullBase — по записи на компанию). Увольнение из одной из них не
|
||||||
|
// означает, что человек ушёл: пока хоть где-то дата увольнения не стоит, он
|
||||||
|
// работает, и напоминание ему по-прежнему нужно.
|
||||||
|
[Fact(DisplayName = "Уволен из одной компании, но работает в другой — напоминание есть")]
|
||||||
|
public async Task DismissedFromOneCompanyOnly_StillBuildsReminder()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation partly dismissed");
|
||||||
|
const string fio = "Романов Роман Романович";
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(
|
||||||
|
[
|
||||||
|
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО АСТЕЛ-К", Now.AddDays(-10)),
|
||||||
|
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО TANSU Construction", null)
|
||||||
|
]);
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), fio, "romanov@tnsu.kz", "ТОО TANSU Construction")]);
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync("romanov@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("romanov@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
|
||||||
|
// ломать сопоставление со справочником (см. EmployeeDirectorySnapshot.Normalize).
|
||||||
|
[Fact(DisplayName = "ФИО сопоставляется без учёта регистра и лишних пробелов")]
|
||||||
|
public async Task CandidateName_IsMatchedLoosely()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation fio normalize");
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", " волков ВОЛК волкович ", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Волков Волк Волкович", dismissalDate: null, candidateEmail: "volkov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("volkov@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
|
||||||
|
public async Task CandidateNotInFullBase_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation unknown");
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Сидоров Сидор Сидорович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||||
|
portalClient.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "До конца испытательного срока не ровно 3 дня — напоминания нет")]
|
||||||
|
public async Task NotExactlyThreeDaysLeft_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation not due");
|
||||||
|
// "1 месяц" от 2026-06-15 → срок кончился 2026-07-15, задолго до Now.
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Кузнецов Кузьма Кузьмич", "2026-06-15T00:00:00Z", "1 месяц") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Кузнецов Кузьма Кузьмич", dismissalDate: null, candidateEmail: "kuznecov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«Без испытательного срока» — напоминания нет")]
|
||||||
|
public async Task NoProbationPeriod_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation none");
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
HireDataRow(1, "Закрыта", "Смирнов Смирн Смирнович", "2026-06-15T00:00:00Z", "Без испытательного срока")
|
||||||
|
};
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Смирнов Смирн Смирнович", dismissalDate: null, candidateEmail: "smirnov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
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
|
||||||
|
// в ReminderService), поэтому заявка, способная дать напоминание сегодня,
|
||||||
|
// физически не может лежать раньше предпоследней партиции. Три партиции:
|
||||||
|
// самая старая (№1) содержит строку-ловушку — если сервис её прочитает, тест
|
||||||
|
// это увидит по лишнему напоминанию; предпоследняя (№2) и текущая (№3)
|
||||||
|
// читаются как обычно.
|
||||||
|
[Fact(DisplayName = "Читаются только текущая и последняя архивная партиция — более старые игнорируются")]
|
||||||
|
public async Task OnlyLastTwoPartitions_AreRead()
|
||||||
|
{
|
||||||
|
var (listUrl1, pageUrl) = await CreateHireTypeAsync("Probation partitions");
|
||||||
|
var db = _services.GetRequiredService<ApprovalRouteDbContext>();
|
||||||
|
var partitionRegistry = _services.GetRequiredService<ListPartitionRegistry>();
|
||||||
|
var type = await db.RequestTypes.FirstAsync(x => x.PageUrl == pageUrl);
|
||||||
|
|
||||||
|
// Партиция №1 — уедет в архив первой, её строки читаться не должны.
|
||||||
|
await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||||
|
var listUrl2 = listUrl1 + "-2";
|
||||||
|
type.ListId = listUrl2;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Партиция №2 — последняя архивная, должна читаться наравне с текущей.
|
||||||
|
await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||||
|
var listUrl3 = listUrl1 + "-3";
|
||||||
|
type.ListId = listUrl3;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
const string fio = "Партиция Тест Тестович";
|
||||||
|
var trapRow = HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца");
|
||||||
|
var realRow = HireDataRow(2, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца");
|
||||||
|
|
||||||
|
var sharePoint = new Mock<ISharePointClient>();
|
||||||
|
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny<CancellationToken>())).ReturnsAsync("list1 — данные");
|
||||||
|
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny<CancellationToken>())).ReturnsAsync("list2 — данные");
|
||||||
|
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny<CancellationToken>())).ReturnsAsync("list3 — данные");
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([Json(trapRow)]);
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([Json(realRow)]);
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([]);
|
||||||
|
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = new ReminderService(
|
||||||
|
db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock,
|
||||||
|
// Порог задан явно (как и в BuildProbationService): тест про партиции, а
|
||||||
|
// не про то, сколько дней до конца срока настроено в проде.
|
||||||
|
Options.Create(new RemindersOptions
|
||||||
|
{
|
||||||
|
RecruitingHirePageUrl = pageUrl,
|
||||||
|
ProbationReminderUrl = ProbationReminderUrl,
|
||||||
|
ProbationReminderDaysBeforeEnd = ProbationDaysBeforeEnd
|
||||||
|
}),
|
||||||
|
NullLogger<ReminderService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>()
|
||||||
|
.CandidateEmail.ShouldBe("partition@tnsu.kz");
|
||||||
|
sharePoint.Verify(
|
||||||
|
x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-28
@@ -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
|
||||||
|
|||||||
+42
-36
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user