Расширение reminders #19
@@ -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<ReminderDto>), 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>
|
||||||
|
|||||||
+9
-10
@@ -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 — напоминания нет")]
|
||||||
|
|||||||
Reference in New Issue
Block a user