diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs
index 5b738fd..9ea1c62 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs
@@ -1,6 +1,10 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
-/// Согласующий/исполнитель из строки списка — кому напоминать.
+///
+/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
+/// именем ради обратной совместимости с потребителями, читавшими это поле до
+/// появления общего ReceiverEmail/ReceiverName в базовом классе.
+///
///
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
/// Выходные не учитываются — считаются календарные часы.
@@ -9,15 +13,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
/// наследованием (шаг перекрывает тип заявки).
///
+/// Id типа заявки (RequestType).
+/// Название типа заявки — для читаемости в письме/таблице, без похода в БД.
+/// Id заявки (TicketId строки списка согласования).
+/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).
+///
+/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
+/// заявки его нет или строка данных не найдена.
+///
public sealed record ApprovalOverdueReminderDto(
string Email,
int HoursOverdue,
bool AskOverdueReason,
- string Url,
int RequestTypeId,
string RequestTypeName,
int RequestId,
string Status,
- string? RequestUniqueCode)
- : ReminderDto(
- ReminderCategory.ApprovalOverdue, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode);
+ string? RequestUniqueCode,
+ string Url,
+ string? ReceiverName = null)
+ : ReminderDto(Email, ReceiverName, Url);
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs
index cd5d224..e48365e 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs
@@ -15,6 +15,7 @@ internal sealed record ApprovalRow(
string Phase,
DateTimeOffset? Date,
string? ApproverEmail,
+ string? ApproverName,
int? SlaHours,
bool? AskOverdueReason)
{
@@ -23,9 +24,9 @@ internal sealed record ApprovalRow(
// TicketId — Lookup на заявку; без него строку не с чем связать.
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
- var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
- ? GetString(a, "EMail")
- : null;
+ var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
+ ? a
+ : (JsonElement?)null;
return new ApprovalRow(
TryGetInt(item, "Id", out var id) ? id : 0,
@@ -35,7 +36,10 @@ internal sealed record ApprovalRow(
// трактуем как «Согласование» (так же поступает request-portal).
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
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,
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs
index 64b8cf0..f82fd86 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs
@@ -31,12 +31,22 @@ internal sealed class EmployeeDirectorySnapshot
{
private readonly ILookup _fullBaseByFio;
private readonly ILookup _contactsByFio;
+ private readonly IReadOnlyDictionary _fioByEmail;
public EmployeeDirectorySnapshot(
IReadOnlyList fullBase, IReadOnlyList 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);
}
///
@@ -64,6 +74,10 @@ internal sealed class EmployeeDirectorySnapshot
.Select(e => e.Email)
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
+ /// ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).
+ public string? NameByEmail(string email) =>
+ _fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
+
///
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
/// регистра и лишних пробелов между словами (в живых данных встречаются и
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs
index d46580f..b42fa84 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs
@@ -1,6 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
-/// Email руководителя принимаемого сотрудника — кому напоминать.
+///
+/// То же значение, что и унаследованный ReceiverEmail, — email руководителя
+/// принимаемого сотрудника, кому адресовано напоминание.
+///
/// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).
/// ФИО принимаемого сотрудника (колонка CandidateName списка данных).
/// Текст испытательного срока (колонка ProbationPeriod).
@@ -16,10 +19,5 @@ public sealed record ProbationEndingReminderDto(
DateTimeOffset ProbationEndsAt,
int DaysUntilProbationEnds,
string Url,
- int RequestTypeId,
- string RequestTypeName,
- int RequestId,
- string Status,
- string? RequestUniqueCode)
- : ReminderDto(
- ReminderCategory.ProbationEnding, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode);
+ string? ReceiverName = null)
+ : ReminderDto(ManagerEmail, ReceiverName, Url);
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs
deleted file mode 100644
index 15faba3..0000000
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderCategory.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace Tnsu.ApprovalRoute.Application.Reminders;
-
-///
-/// Категория уведомления в . Добавлена позже самого DTO,
-/// поэтому в нём параметр необязательный со значением —
-/// «категория не указана»: ровно то, что было до появления этого поля, когда
-/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate)
-/// ни о каких категориях не знали.
-///
-/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs),
-/// так что числовые значения — деталь реализации, менять их порядок безопасно.
-///
-public enum ReminderCategory
-{
- /// Категория не указана — значение по умолчанию для обратной совместимости.
- None = 0,
-
- /// Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync).
- ApprovalOverdue = 1,
-
- /// Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync).
- ProbationEnding = 2,
-}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs
index b3c5ad2..3563423 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs
@@ -2,40 +2,31 @@ using System.Text.Json.Serialization;
namespace Tnsu.ApprovalRoute.Application.Reminders;
-/// Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы.
-/// Ссылка на заявку.
-/// Id типа заявки (RequestType).
-/// Название типа заявки — для читаемости в письме/таблице, без похода в БД.
-/// Id заявки (TicketId строки списка согласования / Id строки списка данных).
-/// Статус заявки/шага — из того списка, который читает конкретный вид напоминания.
-///
-/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
-/// заявки его нет или строка данных не найдена.
+/// Email того, кому адресовано напоминание.
+///
+/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
+/// в старой строке списка без заполненного Title персонального поля) — тогда null.
+///
+///
+/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
+/// потому что не константна: requestId и партиция списка (server-relative url,
+/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
+/// дублировать эту логику снаружи незачем.
///
///
/// Общая часть двух видов напоминаний — и
-/// . У них разные поля-триггеры и разные
-/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка
-/// и как её открыть).
+/// . Категории заявки (RequestTypeId,
+/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
+/// (Power Automate ссылается на неё, работая со списком согласования конкретного
+/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
///
-/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на
-/// compile-time тип элемента (ReminderService всегда работает со списком именно
-/// этого базового типа, см. IReadOnlyList<ReminderDto>), System.Text.Json без
-/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные
-/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.).
-/// Дискриминатор — отдельное системное поле "$type", а не Category: значение
-/// Category читаемое (совпадает с именем enum-константы через уже подключенный
-/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям
-/// сериализации.
+/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
+/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
+/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
+/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
+/// со значением "ApprovalOverdue"/"ProbationEnding".
///
[JsonPolymorphic]
-[JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))]
-[JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))]
-public abstract record ReminderDto(
- ReminderCategory Category,
- string Url,
- int RequestTypeId,
- string RequestTypeName,
- int RequestId,
- string Status,
- string? RequestUniqueCode);
\ No newline at end of file
+[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
+[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
+public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
index 5cfcf3d..1becb20 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
@@ -207,12 +207,13 @@ public sealed class ReminderService(
row.ApproverEmail!,
(int)Math.Floor((now - deadline).TotalHours),
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
- BuildUrl(type, row, listId),
type.Id,
type.Name,
row.TicketId,
row.Status,
- uniqueCodes.GetValueOrDefault(row.TicketId)));
+ uniqueCodes.GetValueOrDefault(row.TicketId),
+ BuildUrl(type, row, listId),
+ row.ApproverName));
}
return reminders;
@@ -249,7 +250,7 @@ public sealed class ReminderService(
private const string RowSelect =
"$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> ReadRowsAsync(
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
@@ -316,7 +317,16 @@ public sealed class ReminderService(
var archived = await partitionRegistry.GetArchivedAsync(type, 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 нет, а нужны
@@ -356,8 +366,14 @@ public sealed class ReminderService(
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}'";
+ $"$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();
@@ -425,11 +441,7 @@ public sealed class ReminderService(
ProbationEndsAt: probationEndsAt,
DaysUntilProbationEnds: daysLeft,
Url: BuildProbationUrl(type, row, listId),
- RequestTypeId: type.Id,
- RequestTypeName: type.Name,
- RequestId: row.Id,
- Status: row.Status,
- RequestUniqueCode: row.RequestUniqueCode);
+ ReceiverName: directory.NameByEmail(managerEmail));
}
///
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs
new file mode 100644
index 0000000..8b6369b
--- /dev/null
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs
@@ -0,0 +1,32 @@
+using Shouldly;
+using Tnsu.ApprovalRoute.Application.Reminders;
+
+namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
+
+///
+/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами
+/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и
+/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см.
+/// ReminderDto) — так переход на новый контракт можно делать постепенно, не
+/// ломая существующие Power Automate-флоу разом.
+///
+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);
+ }
+}
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
index b9ac9f3..a2415ec 100644
--- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -142,7 +143,6 @@ public sealed class ReminderServiceTests : IClassFixture();
- reminder.Category.ShouldBe(ReminderCategory.ProbationEnding);
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz");
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
@@ -471,4 +470,66 @@ public sealed class ReminderServiceTests : IClassFixture();
+ var partitionRegistry = _services.GetRequiredService();
+ 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();
+ sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny())).ReturnsAsync("list1 — данные");
+ sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny())).ReturnsAsync("list2 — данные");
+ sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny())).ReturnsAsync("list3 — данные");
+ sharePoint
+ .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny(), It.IsAny()))
+ .ReturnsAsync([Json(trapRow)]);
+ sharePoint
+ .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny(), It.IsAny()))
+ .ReturnsAsync([Json(realRow)]);
+ sharePoint
+ .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny(), It.IsAny()))
+ .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.Instance);
+
+ var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Value.ShouldHaveSingleItem().ShouldBeOfType()
+ .CandidateEmail.ShouldBe("partition@tnsu.kz");
+ sharePoint.Verify(
+ x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
}