Расширение reminders #19

Merged
gorbenko.pavel merged 8 commits from remimders-extension into main 2026-08-27 11:43:45 +00:00
5 changed files with 99 additions and 68 deletions
Showing only changes of commit 5d7503209a - Show all commits
@@ -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; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param> /// <param name="Category">Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы.</param>
/// <param name="HoursOverdue"> /// <param name="Url">Ссылка на заявку.</param>
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). /// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
/// Выходные не учитываются — считаются календарные часы. /// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
/// </param> /// <param name="RequestId">Id заявки (TicketId строки списка согласования / Id строки списка данных).</param>
/// <param name="Url">Ссылка на заявку, требующую решения.</param> /// <param name="Status">Статус заявки/шага — из того списка, который читает конкретный вид напоминания.</param>
/// <param name="AskOverdueReason">
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
/// наследованием (шаг перекрывает тип заявки).
/// </param>
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
/// <param name="RequestUniqueCode"> /// <param name="RequestUniqueCode">
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у /// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
/// заявки его нет или строка данных не найдена. /// заявки его нет или строка данных не найдена.
/// </param> /// </param>
/// <param name="Category"> /// <remarks>
/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по /// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
/// умолчанию <see cref="ReminderCategory.None"/> («не указана»): старые вызовы /// <see cref="ProbationEndingReminderDto"/>. У них разные поля-триггеры и разные
/// конструктора и потребители, ничего не знающие о категориях, продолжают работать /// адресаты, общее — только то, что описано здесь (к какой заявке относится строка
/// как раньше. /// и как её открыть).
/// </param> ///
/// <param name="ManagerEmail"> /// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на
/// Только для <see cref="ReminderCategory.ProbationEnding"/> — email руководителя /// compile-time тип элемента (ReminderService всегда работает со списком именно
/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для /// этого базового типа, см. IReadOnlyList&lt;ReminderDto&gt;), System.Text.Json без
/// остальных категорий — null. /// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные
/// </param> /// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.).
/// <param name="CandidateEmail">Только для ProbationEnding — email принимаемого сотрудника.</param> /// Дискриминатор — отдельное системное поле "$type", а не Category: значение
/// <param name="CandidateName">Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName).</param> /// Category читаемое (совпадает с именем enum-константы через уже подключенный
/// <param name="ProbationPeriod">Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod).</param> /// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям
/// <param name="HireDate">Только для ProbationEnding — дата приёма (колонка HireDate).</param> /// сериализации.
/// <param name="ProbationEndsAt">Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок).</param> /// </remarks>
/// <param name="DaysUntilProbationEnds">Только для ProbationEnding — сколько дней осталось до конца испытательного срока.</param> [JsonPolymorphic]
public sealed record ReminderDto( [JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))]
string Email, [JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))]
int HoursOverdue, public abstract record ReminderDto(
ReminderCategory Category,
string Url, string Url,
bool AskOverdueReason,
int RequestTypeId, int RequestTypeId,
string RequestTypeName, string RequestTypeName,
int RequestId, int RequestId,
string Status, string Status,
string? RequestUniqueCode, 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,16 @@ 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,
BuildUrl(type, row, listId),
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));
} }
return reminders; return reminders;
@@ -417,24 +416,20 @@ 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),
RequestTypeId: type.Id,
RequestTypeName: type.Name,
RequestId: row.Id,
Status: row.Status,
RequestUniqueCode: row.RequestUniqueCode);
} }
/// <summary> /// <summary>
@@ -138,7 +138,7 @@ 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);
@@ -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,10 @@ 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.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 +406,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 +422,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 — напоминания нет")]