diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs new file mode 100644 index 0000000..5b738fd --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs @@ -0,0 +1,23 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// Согласующий/исполнитель из строки списка — кому напоминать. +/// +/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). +/// Выходные не учитываются — считаются календарные часы. +/// +/// +/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым +/// наследованием (шаг перекрывает тип заявки). +/// +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); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs new file mode 100644 index 0000000..d46580f --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs @@ -0,0 +1,25 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// Email руководителя принимаемого сотрудника — кому напоминать. +/// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). +/// ФИО принимаемого сотрудника (колонка CandidateName списка данных). +/// Текст испытательного срока (колонка ProbationPeriod). +/// Дата приёма (колонка HireDate). +/// Дата окончания испытательного срока (HireDate + срок). +/// Сколько дней осталось до конца испытательного срока. +public sealed record ProbationEndingReminderDto( + string ManagerEmail, + string CandidateEmail, + string CandidateName, + string? ProbationPeriod, + DateTimeOffset HireDate, + 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); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs index b4ecc66..b3c5ad2 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs @@ -1,52 +1,41 @@ +using System.Text.Json.Serialization; + namespace Tnsu.ApprovalRoute.Application.Reminders; -/// Согласующий/исполнитель из строки списка — кому напоминать. -/// -/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). -/// Выходные не учитываются — считаются календарные часы. -/// -/// Ссылка на заявку, требующую решения. -/// -/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым -/// наследованием (шаг перекрывает тип заявки). -/// -/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»). +/// Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы. +/// Ссылка на заявку. +/// Id типа заявки (RequestType). +/// Название типа заявки — для читаемости в письме/таблице, без похода в БД. +/// Id заявки (TicketId строки списка согласования / Id строки списка данных). +/// Статус заявки/шага — из того списка, который читает конкретный вид напоминания. /// /// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у /// заявки его нет или строка данных не найдена. /// -/// -/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по -/// умолчанию («не указана»): старые вызовы -/// конструктора и потребители, ничего не знающие о категориях, продолжают работать -/// как раньше. -/// -/// -/// Только для — email руководителя -/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для -/// остальных категорий — null. -/// -/// Только для ProbationEnding — email принимаемого сотрудника. -/// Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName). -/// Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod). -/// Только для ProbationEnding — дата приёма (колонка HireDate). -/// Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок). -/// Только для ProbationEnding — сколько дней осталось до конца испытательного срока. -public sealed record ReminderDto( - string Email, - int HoursOverdue, +/// +/// Общая часть двух видов напоминаний — и +/// . У них разные поля-триггеры и разные +/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка +/// и как её открыть). +/// +/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на +/// compile-time тип элемента (ReminderService всегда работает со списком именно +/// этого базового типа, см. IReadOnlyList<ReminderDto>), System.Text.Json без +/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные +/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.). +/// Дискриминатор — отдельное системное поле "$type", а не Category: значение +/// Category читаемое (совпадает с именем enum-константы через уже подключенный +/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям +/// сериализации. +/// +[JsonPolymorphic] +[JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))] +[JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))] +public abstract record ReminderDto( + ReminderCategory Category, 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); + string? RequestUniqueCode); \ No newline at end of file diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 032f864..5cfcf3d 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -203,17 +203,16 @@ public sealed class ReminderService( var deadline = activatedAt.AddHours(slaHours); if (deadline >= now) continue; - reminders.Add(new ReminderDto( + reminders.Add(new ApprovalOverdueReminderDto( row.ApproverEmail!, (int)Math.Floor((now - deadline).TotalHours), - BuildUrl(type, row, listId), row.AskOverdueReason ?? type.AskOverdueReason ?? false, + BuildUrl(type, row, listId), type.Id, type.Name, row.TicketId, row.Status, - uniqueCodes.GetValueOrDefault(row.TicketId), - ReminderCategory.ApprovalOverdue)); + uniqueCodes.GetValueOrDefault(row.TicketId))); } return reminders; @@ -417,24 +416,20 @@ public sealed class ReminderService( return null; } - return new ReminderDto( - 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, + return new ProbationEndingReminderDto( ManagerEmail: managerEmail, CandidateEmail: candidateEmail, CandidateName: candidateName, ProbationPeriod: row.ProbationPeriod, - HireDate: row.HireDate, + HireDate: hireDate, ProbationEndsAt: probationEndsAt, - DaysUntilProbationEnds: daysLeft); + DaysUntilProbationEnds: daysLeft, + Url: BuildProbationUrl(type, row, listId), + RequestTypeId: type.Id, + RequestTypeName: type.Name, + RequestId: row.Id, + Status: row.Status, + RequestUniqueCode: row.RequestUniqueCode); } /// diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 683d268..b9ac9f3 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -138,7 +138,7 @@ public sealed class ReminderServiceTests : IClassFixture(); reminder.Email.ShouldBe("approver@tnsu.kz"); reminder.HoursOverdue.ShouldBe(24); reminder.RequestId.ShouldBe(1); @@ -173,7 +173,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(2); } [Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")] @@ -199,7 +199,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5); } // SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом @@ -219,7 +219,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5); } [Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")] @@ -231,7 +231,7 @@ public sealed class ReminderServiceTests : IClassFixture().AskOverdueReason.ShouldBeTrue(); } // Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён @@ -276,7 +276,7 @@ public sealed class ReminderServiceTests : IClassFixture().Email.ShouldBe("approver@tnsu.kz"); } private static object HireDataRow( @@ -355,11 +355,10 @@ public sealed class ReminderServiceTests : IClassFixture(); reminder.Category.ShouldBe(ReminderCategory.ProbationEnding); reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); - reminder.Email.ShouldBe("manager@tnsu.kz"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); reminder.DaysUntilProbationEnds.ShouldBe(3); } @@ -407,7 +406,7 @@ public sealed class ReminderServiceTests : IClassFixture().CandidateEmail.ShouldBe("romanov@tnsu.kz"); } // ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны @@ -423,7 +422,7 @@ public sealed class ReminderServiceTests : IClassFixture().CandidateEmail.ShouldBe("volkov@tnsu.kz"); } [Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]