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

Merged
gorbenko.pavel merged 8 commits from remimders-extension into main 2026-08-27 11:43:45 +00:00
2 changed files with 45 additions and 3 deletions
Showing only changes of commit 738e1671c6 - Show all commits
@@ -312,6 +312,19 @@ public sealed class ReminderService(
return result;
}
/// <summary>
/// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
///
/// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
/// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
/// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
/// портале, и напоминание уходило бы на день раньше срока.
///
/// Константой, а не настройкой: смещение фиксировано для всей организации и не
/// меняется от окружения (перехода на летнее время нет).
/// </summary>
private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
new Dictionary<string, int>
{
@@ -424,8 +437,11 @@ public sealed class ReminderService(
if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
var probationEndsAt = hireDate.AddMonths(months);
var daysLeft = (probationEndsAt.Date - now.Date).Days;
// Обе даты — в местном поясе (см. LocalOffset): и дату приёма, и «сегодня»
// надо брать по тому же календарю, который видит человек в портале.
var hireDateLocal = hireDate.ToOffset(LocalOffset);
var probationEndsAt = hireDateLocal.AddMonths(months);
var daysLeft = (probationEndsAt.Date - now.ToOffset(LocalOffset).Date).Days;
if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null;
var candidateName = row.CandidateName.Trim();
@@ -470,7 +486,9 @@ public sealed class ReminderService(
CandidateEmail: candidateEmail,
CandidateName: candidateName,
ProbationPeriod: row.ProbationPeriod,
HireDate: hireDate,
// Тоже в местном поясе — иначе в письме дата приёма была бы на сутки
// раньше той, от которой посчитан ProbationEndsAt рядом.
HireDate: hireDateLocal,
ProbationEndsAt: probationEndsAt,
DaysUntilProbationEnds: daysLeft,
// Не ссылка на саму заявку на приём (она уже закрыта, решать по ней
@@ -522,6 +522,30 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
result.Value.ShouldBeEmpty();
}
// Портал пишет дату приёма как МЕСТНУЮ полночь, переведённую в UTC, поэтому в
// списке она лежит как 19:00Z предыдущего дня. Здесь 2026-06-14T19:00Z — это
// 15 июня по местному календарю (см. LocalOffset в ReminderService): срок
// истекает 15 августа, до него от Now (12 августа) ровно 3 дня.
// Если считать по UTC, дата приёма превратилась бы в 14 июня, срок — в 14
// августа, и осталось бы 2 дня: напоминание не построилось бы вовсе.
[Fact(DisplayName = "Дата приёма считается по местному календарю, а не по UTC")]
public async Task HireDate_IsReadInLocalTimeZone()
{
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation timezone");
var rows = new[] { HireDataRow(1, "Закрыта", "Тимуров Тимур Тимурович", "2026-06-14T19:00:00Z", "2 месяца") };
var portalClient = BuildPortalClientMock(
"Тимуров Тимур Тимурович", dismissalDate: null, candidateEmail: "timurov@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.DaysUntilProbationEnds.ShouldBe(3);
// И дата приёма, и конец срока отдаются в том же местном календаре.
reminder.HireDate.Date.ShouldBe(new DateTime(2026, 6, 15));
reminder.ProbationEndsAt.Date.ShouldBe(new DateTime(2026, 8, 15));
}
// Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption
// в ReminderService), поэтому заявка, способная дать напоминание сегодня,
// физически не может лежать раньше предпоследней партиции. Три партиции: