Reminders: убрать Category из общей части DTO, добавить ReceiverEmail/ReceiverName, ограничить чтение испытательного срока последними партициями
Tests / frontend-test (pull_request) Successful in 45s
Tests / backend-test (pull_request) Successful in 1m54s

- ReminderDto: базовый класс сузился до ReceiverEmail/ReceiverName/Url;
  RequestTypeId/RequestTypeName/RequestId/Status/RequestUniqueCode остались
  только в ApprovalOverdueReminderDto — ProbationEndingReminderDto они не
  нужны. Дискриминатор полиморфной сериализации ($type) больше не дублируется
  отдельным enum-полем Category.
- ApprovalOverdueReminderDto: старый Email сохранён рядом с унаследованным
  ReceiverEmail (то же значение) — контракт можно переключать постепенно.
- ApprovalRow/RowSelect: добавлен Title согласующего (TansuSiteApprover/Title)
  — заполняет ReceiverName для просроченных шагов, когда он есть в SharePoint.
- EmployeeDirectorySnapshot.NameByEmail: обратный поиск ФИО руководителя по
  email — заполняет ReceiverName для напоминаний об испытательном сроке без
  лишнего похода в api.tnsu.kz (данные уже загружены для EmailOf).
- GetProbationEndingAsync: испытательный срок не длиннее 6 месяцев, поэтому
  читаем только текущую и последнюю архивную партицию (не весь архив) и
  фильтруем строки по HireDate не старше 6 месяцев — экономим лишние запросы
  к SharePoint без потери актуальных заявок.
This commit is contained in:
gorbenko.pavel
2026-08-27 14:12:39 +05:00
parent 5d7503209a
commit cfc2b393bd
9 changed files with 184 additions and 83 deletions
@@ -1,6 +1,10 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
/// <param name="Email">
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
/// именем ради обратной совместимости с потребителями, читавшими это поле до
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
/// </param>
/// <param name="HoursOverdue">
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
/// Выходные не учитываются — считаются календарные часы.
@@ -9,15 +13,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
/// наследованием (шаг перекрывает тип заявки).
/// </param>
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
/// <param name="RequestUniqueCode">
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
/// заявки его нет или строка данных не найдена.
/// </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);
string? RequestUniqueCode,
string Url,
string? ReceiverName = null)
: ReminderDto(Email, ReceiverName, Url);
@@ -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);
}
@@ -31,12 +31,22 @@ internal sealed class EmployeeDirectorySnapshot
{
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
public EmployeeDirectorySnapshot(
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> 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);
}
/// <summary>
@@ -64,6 +74,10 @@ internal sealed class EmployeeDirectorySnapshot
.Select(e => e.Email)
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
public string? NameByEmail(string email) =>
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
/// <summary>
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
/// регистра и лишних пробелов между словами (в живых данных встречаются и
@@ -1,6 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="ManagerEmail">Email руководителя принимаемого сотрудника — кому напоминать.</param>
/// <param name="ManagerEmail">
/// То же значение, что и унаследованный ReceiverEmail, — email руководителя
/// принимаемого сотрудника, кому адресовано напоминание.
/// </param>
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
@@ -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);
@@ -1,23 +0,0 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Категория уведомления в <see cref="ReminderDto"/>. Добавлена позже самого DTO,
/// поэтому в нём параметр необязательный со значением <see cref="None"/> —
/// «категория не указана»: ровно то, что было до появления этого поля, когда
/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate)
/// ни о каких категориях не знали.
///
/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs),
/// так что числовые значения — деталь реализации, менять их порядок безопасно.
/// </summary>
public enum ReminderCategory
{
/// <summary>Категория не указана — значение по умолчанию для обратной совместимости.</summary>
None = 0,
/// <summary>Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync).</summary>
ApprovalOverdue = 1,
/// <summary>Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync).</summary>
ProbationEnding = 2,
}
@@ -2,40 +2,31 @@ using System.Text.Json.Serialization;
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <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 name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
/// <param name="ReceiverName">
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
/// </param>
/// <param name="Url">
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
/// потому что не константна: requestId и партиция списка (server-relative url,
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
/// дублировать эту логику снаружи незачем.
/// </param>
/// <remarks>
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
/// <see cref="ProbationEndingReminderDto"/>. У них разные поля-триггеры и разные
/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка
/// и как её открыть).
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
///
/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на
/// compile-time тип элемента (ReminderService всегда работает со списком именно
/// этого базового типа, см. IReadOnlyList&lt;ReminderDto&gt;), System.Text.Json без
/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные
/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.).
/// Дискриминатор — отдельное системное поле "$type", а не Category: значение
/// Category читаемое (совпадает с именем enum-константы через уже подключенный
/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям
/// сериализации.
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
/// список именно базового типа (IReadOnlyList&lt;ReminderDto&gt;) — без этих
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
/// со значением "ApprovalOverdue"/"ProbationEnding".
/// </remarks>
[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);
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
@@ -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<IReadOnlyList<ApprovalRow>> 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));
}
/// <summary>