ReminderDto: разделить на ApprovalOverdueReminderDto и ProbationEndingReminderDto

Абстрактный базовый класс + два sealed-наследника вместо одного DTO с
опциональными полями под обе категории — так специфичные для каждого
вида напоминания поля не путаются с полями другого вида. Полиморфная
сериализация System.Text.Json ([JsonPolymorphic]/[JsonDerivedType])
нужна, иначе список IReadOnlyList<ReminderDto> сериализовался бы только
по общим полям базового класса.
This commit is contained in:
gorbenko.pavel
2026-08-27 12:31:45 +05:00
parent 874e41990c
commit 5d7503209a
5 changed files with 99 additions and 68 deletions
@@ -0,0 +1,23 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
/// <param name="HoursOverdue">
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
/// Выходные не учитываются — считаются календарные часы.
/// </param>
/// <param name="AskOverdueReason">
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
/// наследованием (шаг перекрывает тип заявки).
/// </param>
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);
@@ -0,0 +1,25 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="ManagerEmail">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,
int RequestTypeId,
string RequestTypeName,
int RequestId,
string Status,
string? RequestUniqueCode)
: ReminderDto(
ReminderCategory.ProbationEnding, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode);
@@ -1,52 +1,41 @@
using System.Text.Json.Serialization;
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
/// <param name="HoursOverdue">
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
/// Выходные не учитываются — считаются календарные часы.
/// </param>
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
/// <param name="AskOverdueReason">
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
/// наследованием (шаг перекрывает тип заявки).
/// </param>
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
/// <param name="Category">Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы.</param>
/// <param name="Url">Ссылка на заявку.</param>
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
/// <param name="RequestId">Id заявки (TicketId строки списка согласования / Id строки списка данных).</param>
/// <param name="Status">Статус заявки/шага — из того списка, который читает конкретный вид напоминания.</param>
/// <param name="RequestUniqueCode">
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
/// заявки его нет или строка данных не найдена.
/// </param>
/// <param name="Category">
/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по
/// умолчанию <see cref="ReminderCategory.None"/> («не указана»): старые вызовы
/// конструктора и потребители, ничего не знающие о категориях, продолжают работать
/// как раньше.
/// </param>
/// <param name="ManagerEmail">
/// Только для <see cref="ReminderCategory.ProbationEnding"/> — email руководителя
/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для
/// остальных категорий — null.
/// </param>
/// <param name="CandidateEmail">Только для ProbationEnding — email принимаемого сотрудника.</param>
/// <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,
/// <remarks>
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
/// <see cref="ProbationEndingReminderDto"/>. У них разные поля-триггеры и разные
/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка
/// и как её открыть).
///
/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на
/// compile-time тип элемента (ReminderService всегда работает со списком именно
/// этого базового типа, см. IReadOnlyList&lt;ReminderDto&gt;), System.Text.Json без
/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные
/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.).
/// Дискриминатор — отдельное системное поле "$type", а не Category: значение
/// Category читаемое (совпадает с именем enum-константы через уже подключенный
/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям
/// сериализации.
/// </remarks>
[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);
@@ -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);
}
/// <summary>
@@ -138,7 +138,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
var reminder = result.Value.ShouldHaveSingleItem();
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>();
reminder.Email.ShouldBe("approver@tnsu.kz");
reminder.HoursOverdue.ShouldBe(24);
reminder.RequestId.ShouldBe(1);
@@ -173,7 +173,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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 пуста) — пропускается, а не падает")]
@@ -199,7 +199,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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»), и на этом
@@ -219,7 +219,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
}
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
@@ -231,7 +231,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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);
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz");
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().Email.ShouldBe("approver@tnsu.kz");
}
private static object HireDataRow(
@@ -355,11 +355,10 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
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.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<PostgresDatabaseFixture
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
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 +422,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
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 — напоминания нет")]