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); + } }