2 Commits

Author SHA1 Message Date
gorbenko.pavel cfc2b393bd Reminders: убрать Category из общей части DTO, добавить ReceiverEmail/ReceiverName, ограничить чтение испытательного срока последними партициями
Tests / frontend-test (pull_request) Successful in 45s
Tests / backend-test (pull_request) Successful in 1m54s
- ReminderDto: базовый класс сузился до ReceiverEmail/ReceiverName/Url;
  RequestTypeId/RequestTypeName/RequestId/Status/RequestUniqueCode остались
  только в ApprovalOverdueReminderDto — ProbationEndingReminderDto они не
  нужны. Дискриминатор полиморфной сериализации ($type) больше не дублируется
  отдельным enum-полем Category.
- ApprovalOverdueReminderDto: старый Email сохранён рядом с унаследованным
  ReceiverEmail (то же значение) — контракт можно переключать постепенно.
- ApprovalRow/RowSelect: добавлен Title согласующего (TansuSiteApprover/Title)
  — заполняет ReceiverName для просроченных шагов, когда он есть в SharePoint.
- EmployeeDirectorySnapshot.NameByEmail: обратный поиск ФИО руководителя по
  email — заполняет ReceiverName для напоминаний об испытательном сроке без
  лишнего похода в api.tnsu.kz (данные уже загружены для EmailOf).
- GetProbationEndingAsync: испытательный срок не длиннее 6 месяцев, поэтому
  читаем только текущую и последнюю архивную партицию (не весь архив) и
  фильтруем строки по HireDate не старше 6 месяцев — экономим лишние запросы
  к SharePoint без потери актуальных заявок.
2026-08-27 14:12:39 +05:00
gorbenko.pavel 5d7503209a ReminderDto: разделить на ApprovalOverdueReminderDto и ProbationEndingReminderDto
Абстрактный базовый класс + два sealed-наследника вместо одного DTO с
опциональными полями под обе категории — так специфичные для каждого
вида напоминания поля не путаются с полями другого вида. Полиморфная
сериализация System.Text.Json ([JsonPolymorphic]/[JsonDerivedType])
нужна, иначе список IReadOnlyList<ReminderDto> сериализовался бы только
по общим полям базового класса.
2026-08-27 12:31:45 +05:00
9 changed files with 238 additions and 106 deletions
@@ -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);
} }
@@ -31,12 +31,22 @@ internal sealed class EmployeeDirectorySnapshot
{ {
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio; private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio; private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
public EmployeeDirectorySnapshot( public EmployeeDirectorySnapshot(
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts) IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
{ {
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio)); _fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
_contactsByFio = contacts.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>
@@ -64,6 +74,10 @@ internal sealed class EmployeeDirectorySnapshot
.Select(e => e.Email) .Select(e => e.Email)
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email)); .FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
public string? NameByEmail(string email) =>
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
/// <summary> /// <summary>
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта /// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
/// регистра и лишних пробелов между словами (в живых данных встречаются и /// регистра и лишних пробелов между словами (в живых данных встречаются и
@@ -0,0 +1,23 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="ManagerEmail">
/// То же значение, что и унаследованный ReceiverEmail, — email руководителя
/// принимаемого сотрудника, кому адресовано напоминание.
/// </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 ManagerEmail,
string CandidateEmail,
string CandidateName,
string? ProbationPeriod,
DateTimeOffset HireDate,
DateTimeOffset ProbationEndsAt,
int DaysUntilProbationEnds,
string Url,
string? ReceiverName = null)
: ReminderDto(ManagerEmail, ReceiverName, Url);
@@ -1,23 +0,0 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Категория уведомления в <see cref="ReminderDto"/>. Добавлена позже самого DTO,
/// поэтому в нём параметр необязательный со значением <see cref="None"/> —
/// «категория не указана»: ровно то, что было до появления этого поля, когда
/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate)
/// ни о каких категориях не знали.
///
/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs),
/// так что числовые значения — деталь реализации, менять их порядок безопасно.
/// </summary>
public enum ReminderCategory
{
/// <summary>Категория не указана — значение по умолчанию для обратной совместимости.</summary>
None = 0,
/// <summary>Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync).</summary>
ApprovalOverdue = 1,
/// <summary>Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync).</summary>
ProbationEnding = 2,
}
@@ -1,52 +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 ссылается на неё, работая со списком согласования конкретного
/// <param name="Category"> /// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по ///
/// умолчанию <see cref="ReminderCategory.None"/> («не указана»): старые вызовы /// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
/// конструктора и потребители, ничего не знающие о категориях, продолжают работать /// список именно базового типа (IReadOnlyList&lt;ReminderDto&gt;) — без этих
/// как раньше. /// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
/// </param> /// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
/// <param name="ManagerEmail"> /// со значением "ApprovalOverdue"/"ProbationEnding".
/// Только для <see cref="ReminderCategory.ProbationEnding"/> — email руководителя /// </remarks>
/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для [JsonPolymorphic]
/// остальных категорий — null. [JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
/// </param> [JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
/// <param name="CandidateEmail">Только для ProbationEnding — email принимаемого сотрудника.</param> public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
/// <param name="CandidateName">Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName).</param>
/// <param name="ProbationPeriod">Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod).</param>
/// <param name="HireDate">Только для ProbationEnding — дата приёма (колонка HireDate).</param>
/// <param name="ProbationEndsAt">Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок).</param>
/// <param name="DaysUntilProbationEnds">Только для ProbationEnding — сколько дней осталось до конца испытательного срока.</param>
public sealed record ReminderDto(
string Email,
int HoursOverdue,
string Url,
bool AskOverdueReason,
int RequestTypeId,
string RequestTypeName,
int RequestId,
string Status,
string? RequestUniqueCode,
ReminderCategory Category = ReminderCategory.None,
string? ManagerEmail = null,
string? CandidateEmail = null,
string? CandidateName = null,
string? ProbationPeriod = null,
DateTimeOffset? HireDate = null,
DateTimeOffset? ProbationEndsAt = null,
int? DaysUntilProbationEnds = null);
@@ -203,17 +203,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),
ReminderCategory.ApprovalOverdue)); BuildUrl(type, row, listId),
row.ApproverName));
} }
return reminders; return reminders;
@@ -250,7 +250,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)
@@ -317,7 +317,16 @@ public sealed class ReminderService(
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
var partitions = archived.Select(x => (x.ListId, x.Number)).Append((current.ListId, current.Number));
// Испытательный срок — не больше 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 нет, а нужны // на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
@@ -357,8 +366,14 @@ public sealed class ReminderService(
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
if (string.IsNullOrWhiteSpace(dataListTitle)) return []; if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
// HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две
// последних партиции (см. GetProbationEndingAsync): более старый приём уже
// не может дать напоминание, читать его строки незачем даже в пределах
// партиции, где они могут лежать вперемешку со свежими.
var since = now.AddMonths(-6);
var filter = var filter =
$"$filter=RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}'"; $"$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( var items = await sharePoint.GetItemsByListTitleAsync(
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken); siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
@@ -417,24 +432,16 @@ public sealed class ReminderService(
return null; return null;
} }
return new ReminderDto( return new ProbationEndingReminderDto(
Email: managerEmail,
HoursOverdue: 0,
Url: BuildProbationUrl(type, row, listId),
AskOverdueReason: false,
RequestTypeId: type.Id,
RequestTypeName: type.Name,
RequestId: row.Id,
Status: row.Status,
RequestUniqueCode: row.RequestUniqueCode,
Category: ReminderCategory.ProbationEnding,
ManagerEmail: managerEmail, ManagerEmail: managerEmail,
CandidateEmail: candidateEmail, CandidateEmail: candidateEmail,
CandidateName: candidateName, CandidateName: candidateName,
ProbationPeriod: row.ProbationPeriod, ProbationPeriod: row.ProbationPeriod,
HireDate: row.HireDate, HireDate: hireDate,
ProbationEndsAt: probationEndsAt, ProbationEndsAt: probationEndsAt,
DaysUntilProbationEnds: daysLeft); DaysUntilProbationEnds: daysLeft,
Url: BuildProbationUrl(type, row, listId),
ReceiverName: directory.NameByEmail(managerEmail));
} }
/// <summary> /// <summary>
@@ -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);
}
}
@@ -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;
@@ -138,11 +139,10 @@ 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);
reminder.Category.ShouldBe(ReminderCategory.ApprovalOverdue);
} }
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
@@ -173,7 +173,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 пуста) — пропускается, а не падает")]
@@ -199,7 +199,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»), и на этом
@@ -219,7 +219,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 = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
@@ -231,7 +231,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();
} }
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён // Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
@@ -276,7 +276,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().Email.ShouldBe("approver@tnsu.kz"); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().Email.ShouldBe("approver@tnsu.kz");
} }
private static object HireDataRow( private static object HireDataRow(
@@ -355,11 +355,9 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.IsSuccess.ShouldBeTrue(); result.IsSuccess.ShouldBeTrue();
var reminder = result.Value.ShouldHaveSingleItem(); var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.Category.ShouldBe(ReminderCategory.ProbationEnding);
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); reminder.ManagerEmail.ShouldBe("manager@tnsu.kz");
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
reminder.Email.ShouldBe("manager@tnsu.kz");
reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
reminder.DaysUntilProbationEnds.ShouldBe(3); reminder.DaysUntilProbationEnds.ShouldBe(3);
} }
@@ -407,7 +405,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().CandidateEmail.ShouldBe("romanov@tnsu.kz"); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("romanov@tnsu.kz");
} }
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны // ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
@@ -423,7 +421,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().CandidateEmail.ShouldBe("volkov@tnsu.kz"); result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("volkov@tnsu.kz");
} }
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")] [Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
@@ -472,4 +470,66 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
result.Value.ShouldBeEmpty(); result.Value.ShouldBeEmpty();
} }
// Испытательный срок не бывает длиннее 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,
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }),
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);
}
} }