Reminders: убрать Category из общей части DTO, добавить ReceiverEmail/ReceiverName, ограничить чтение испытательного срока последними партициями
- 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:
@@ -1,6 +1,10 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
/// <param name="Email">
|
||||||
|
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
|
||||||
|
/// именем ради обратной совместимости с потребителями, читавшими это поле до
|
||||||
|
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
|
||||||
|
/// </param>
|
||||||
/// <param name="HoursOverdue">
|
/// <param name="HoursOverdue">
|
||||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||||
/// Выходные не учитываются — считаются календарные часы.
|
/// Выходные не учитываются — считаются календарные часы.
|
||||||
@@ -9,15 +13,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||||
/// наследованием (шаг перекрывает тип заявки).
|
/// наследованием (шаг перекрывает тип заявки).
|
||||||
/// </param>
|
/// </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(
|
public sealed record ApprovalOverdueReminderDto(
|
||||||
string Email,
|
string Email,
|
||||||
int HoursOverdue,
|
int HoursOverdue,
|
||||||
bool AskOverdueReason,
|
bool AskOverdueReason,
|
||||||
string Url,
|
|
||||||
int RequestTypeId,
|
int RequestTypeId,
|
||||||
string RequestTypeName,
|
string RequestTypeName,
|
||||||
int RequestId,
|
int RequestId,
|
||||||
string Status,
|
string Status,
|
||||||
string? RequestUniqueCode)
|
string? RequestUniqueCode,
|
||||||
: ReminderDto(
|
string Url,
|
||||||
ReminderCategory.ApprovalOverdue, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode);
|
string? ReceiverName = null)
|
||||||
|
: ReminderDto(Email, ReceiverName, Url);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ internal sealed record ApprovalRow(
|
|||||||
string Phase,
|
string Phase,
|
||||||
DateTimeOffset? Date,
|
DateTimeOffset? Date,
|
||||||
string? ApproverEmail,
|
string? ApproverEmail,
|
||||||
|
string? ApproverName,
|
||||||
int? SlaHours,
|
int? SlaHours,
|
||||||
bool? AskOverdueReason)
|
bool? AskOverdueReason)
|
||||||
{
|
{
|
||||||
@@ -23,9 +24,9 @@ internal sealed record ApprovalRow(
|
|||||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||||
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||||
|
|
||||||
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||||
? GetString(a, "EMail")
|
? a
|
||||||
: null;
|
: (JsonElement?)null;
|
||||||
|
|
||||||
return new ApprovalRow(
|
return new ApprovalRow(
|
||||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||||
@@ -35,7 +36,10 @@ internal sealed record ApprovalRow(
|
|||||||
// трактуем как «Согласование» (так же поступает request-portal).
|
// трактуем как «Согласование» (так же поступает request-portal).
|
||||||
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||||
TryGetDate(item, "Date"),
|
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,
|
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : 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, EmployeeFullBaseInfo> _fullBaseByFio;
|
||||||
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
||||||
|
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||||||
|
|
||||||
public EmployeeDirectorySnapshot(
|
public EmployeeDirectorySnapshot(
|
||||||
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
||||||
{
|
{
|
||||||
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
||||||
_contactsByFio = contacts.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>
|
/// <summary>
|
||||||
@@ -64,6 +74,10 @@ internal sealed class EmployeeDirectorySnapshot
|
|||||||
.Select(e => e.Email)
|
.Select(e => e.Email)
|
||||||
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
||||||
|
|
||||||
|
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||||||
|
public string? NameByEmail(string email) =>
|
||||||
|
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
||||||
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
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="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
|
||||||
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
|
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
|
||||||
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
|
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
|
||||||
@@ -16,10 +19,5 @@ public sealed record ProbationEndingReminderDto(
|
|||||||
DateTimeOffset ProbationEndsAt,
|
DateTimeOffset ProbationEndsAt,
|
||||||
int DaysUntilProbationEnds,
|
int DaysUntilProbationEnds,
|
||||||
string Url,
|
string Url,
|
||||||
int RequestTypeId,
|
string? ReceiverName = null)
|
||||||
string RequestTypeName,
|
: ReminderDto(ManagerEmail, ReceiverName, Url);
|
||||||
int RequestId,
|
|
||||||
string Status,
|
|
||||||
string? RequestUniqueCode)
|
|
||||||
: ReminderDto(
|
|
||||||
ReminderCategory.ProbationEnding, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode);
|
|
||||||
|
|||||||
@@ -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;
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
/// <param name="Category">Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы.</param>
|
/// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
|
||||||
/// <param name="Url">Ссылка на заявку.</param>
|
/// <param name="ReceiverName">
|
||||||
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
|
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
|
||||||
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
|
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
|
||||||
/// <param name="RequestId">Id заявки (TicketId строки списка согласования / Id строки списка данных).</param>
|
/// </param>
|
||||||
/// <param name="Status">Статус заявки/шага — из того списка, который читает конкретный вид напоминания.</param>
|
/// <param name="Url">
|
||||||
/// <param name="RequestUniqueCode">
|
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
|
||||||
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
/// потому что не константна: requestId и партиция списка (server-relative url,
|
||||||
/// заявки его нет или строка данных не найдена.
|
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
|
||||||
|
/// дублировать эту логику снаружи незачем.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
||||||
/// <see cref="ProbationEndingReminderDto"/>. У них разные поля-триггеры и разные
|
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
|
||||||
/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка
|
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
|
||||||
/// и как её открыть).
|
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
|
||||||
|
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
|
||||||
///
|
///
|
||||||
/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на
|
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
|
||||||
/// compile-time тип элемента (ReminderService всегда работает со списком именно
|
/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
|
||||||
/// этого базового типа, см. IReadOnlyList<ReminderDto>), System.Text.Json без
|
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
|
||||||
/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные
|
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
|
||||||
/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.).
|
/// со значением "ApprovalOverdue"/"ProbationEnding".
|
||||||
/// Дискриминатор — отдельное системное поле "$type", а не Category: значение
|
|
||||||
/// Category читаемое (совпадает с именем enum-константы через уже подключенный
|
|
||||||
/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям
|
|
||||||
/// сериализации.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPolymorphic]
|
[JsonPolymorphic]
|
||||||
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))]
|
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
|
||||||
[JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))]
|
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
|
||||||
public abstract record ReminderDto(
|
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
|
||||||
ReminderCategory Category,
|
|
||||||
string Url,
|
|
||||||
int RequestTypeId,
|
|
||||||
string RequestTypeName,
|
|
||||||
int RequestId,
|
|
||||||
string Status,
|
|
||||||
string? RequestUniqueCode);
|
|
||||||
|
|||||||
@@ -207,12 +207,13 @@ public sealed class ReminderService(
|
|||||||
row.ApproverEmail!,
|
row.ApproverEmail!,
|
||||||
(int)Math.Floor((now - deadline).TotalHours),
|
(int)Math.Floor((now - deadline).TotalHours),
|
||||||
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),
|
||||||
|
BuildUrl(type, row, listId),
|
||||||
|
row.ApproverName));
|
||||||
}
|
}
|
||||||
|
|
||||||
return reminders;
|
return reminders;
|
||||||
@@ -249,7 +250,7 @@ public sealed class ReminderService(
|
|||||||
|
|
||||||
private const string RowSelect =
|
private const string RowSelect =
|
||||||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
"$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(
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||||||
@@ -316,7 +317,16 @@ public sealed class ReminderService(
|
|||||||
|
|
||||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, 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 нет, а нужны
|
// на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
|
||||||
@@ -356,8 +366,14 @@ public sealed class ReminderService(
|
|||||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
|
if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
|
||||||
|
|
||||||
|
// HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две
|
||||||
|
// последних партиции (см. GetProbationEndingAsync): более старый приём уже
|
||||||
|
// не может дать напоминание, читать его строки незачем даже в пределах
|
||||||
|
// партиции, где они могут лежать вперемешку со свежими.
|
||||||
|
var since = now.AddMonths(-6);
|
||||||
var filter =
|
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(
|
var items = await sharePoint.GetItemsByListTitleAsync(
|
||||||
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
|
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
|
||||||
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
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,
|
ProbationEndsAt: probationEndsAt,
|
||||||
DaysUntilProbationEnds: daysLeft,
|
DaysUntilProbationEnds: daysLeft,
|
||||||
Url: BuildProbationUrl(type, row, listId),
|
Url: BuildProbationUrl(type, row, listId),
|
||||||
RequestTypeId: type.Id,
|
ReceiverName: directory.NameByEmail(managerEmail));
|
||||||
RequestTypeName: type.Name,
|
|
||||||
RequestId: row.Id,
|
|
||||||
Status: row.Status,
|
|
||||||
RequestUniqueCode: row.RequestUniqueCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами
|
||||||
|
/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и
|
||||||
|
/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см.
|
||||||
|
/// ReminderDto) — так переход на новый контракт можно делать постепенно, не
|
||||||
|
/// ломая существующие Power Automate-флоу разом.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-2
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -142,7 +143,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
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);
|
||||||
reminder.Category.ShouldBe(ReminderCategory.ApprovalOverdue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
||||||
@@ -356,7 +356,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
result.IsSuccess.ShouldBeTrue();
|
result.IsSuccess.ShouldBeTrue();
|
||||||
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
|
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
|
||||||
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.CandidateName.ShouldBe("Иванов Иван Иванович");
|
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
|
||||||
@@ -471,4 +470,66 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
result.Value.ShouldBeEmpty();
|
result.Value.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption
|
||||||
|
// в ReminderService), поэтому заявка, способная дать напоминание сегодня,
|
||||||
|
// физически не может лежать раньше предпоследней партиции. Три партиции:
|
||||||
|
// самая старая (№1) содержит строку-ловушку — если сервис её прочитает, тест
|
||||||
|
// это увидит по лишнему напоминанию; предпоследняя (№2) и текущая (№3)
|
||||||
|
// читаются как обычно.
|
||||||
|
[Fact(DisplayName = "Читаются только текущая и последняя архивная партиция — более старые игнорируются")]
|
||||||
|
public async Task OnlyLastTwoPartitions_AreRead()
|
||||||
|
{
|
||||||
|
var (listUrl1, pageUrl) = await CreateHireTypeAsync("Probation partitions");
|
||||||
|
var db = _services.GetRequiredService<ApprovalRouteDbContext>();
|
||||||
|
var partitionRegistry = _services.GetRequiredService<ListPartitionRegistry>();
|
||||||
|
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<ISharePointClient>();
|
||||||
|
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny<CancellationToken>())).ReturnsAsync("list1 — данные");
|
||||||
|
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny<CancellationToken>())).ReturnsAsync("list2 — данные");
|
||||||
|
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny<CancellationToken>())).ReturnsAsync("list3 — данные");
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([Json(trapRow)]);
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([Json(realRow)]);
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<ReminderService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>()
|
||||||
|
.CandidateEmail.ShouldBe("partition@tnsu.kz");
|
||||||
|
sharePoint.Verify(
|
||||||
|
x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Never);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user