diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..9a3aefb
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,4 @@
+# Стайл-гайд для Клода
+
+- У `enum` первое значение (0) — всегда `None`.
+- Сборка не должна выдавать ни одного warning.
diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs
index 5e5d3e4..4d61bfe 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.Api.Controllers;
///
-/// Просроченные шаги согласования и исполнения на текущий момент.
+/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
+/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
+/// появления ProbationEnding.
///
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
{
[HttpGet]
[ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
- public async Task List(CancellationToken ct) =>
- (await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
+ public async Task List(
+ [FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
+ (await service.GetRemindersAsync(feed, ct)).ToActionResult();
}
diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs
index 16109f4..48a3aee 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Program.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs
@@ -52,6 +52,7 @@ builder.Services.AddCors(options =>
.AllowAnyHeader()
.AllowAnyMethod());
});
+builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication();
builder.Services.Configure(builder.Configuration.GetSection("Reminders"));
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs
new file mode 100644
index 0000000..9ea1c62
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalOverdueReminderDto.cs
@@ -0,0 +1,35 @@
+namespace Tnsu.ApprovalRoute.Application.Reminders;
+
+///
+/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
+/// именем ради обратной совместимости с потребителями, читавшими это поле до
+/// появления общего ReceiverEmail/ReceiverName в базовом классе.
+///
+///
+/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
+/// Выходные не учитываются — считаются календарные часы.
+///
+///
+/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
+/// наследованием (шаг перекрывает тип заявки).
+///
+/// Id типа заявки (RequestType).
+/// Название типа заявки — для читаемости в письме/таблице, без похода в БД.
+/// Id заявки (TicketId строки списка согласования).
+/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).
+///
+/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
+/// заявки его нет или строка данных не найдена.
+///
+public sealed record ApprovalOverdueReminderDto(
+ string Email,
+ int HoursOverdue,
+ bool AskOverdueReason,
+ int RequestTypeId,
+ string RequestTypeName,
+ int RequestId,
+ string Status,
+ string? RequestUniqueCode,
+ string Url,
+ string? ReceiverName = null)
+ : ReminderDto(Email, ReceiverName, Url);
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs
index cd5d224..e48365e 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs
@@ -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);
}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs
new file mode 100644
index 0000000..9ea516d
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/EmployeeDirectorySnapshot.cs
@@ -0,0 +1,95 @@
+using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
+
+namespace Tnsu.ApprovalRoute.Application.Reminders;
+
+internal enum EmploymentStatus
+{
+ ///
+ /// Статус не определяли. EmploymentOf такого не возвращает — значение существует
+ /// только как значение по умолчанию (default(EmploymentStatus)); трактуется
+ /// наравне с , то есть «трудоустройство не подтверждено».
+ ///
+ None = 0,
+
+ /// Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.
+ NotFound = 1,
+
+ /// Работает хотя бы в одной компании.
+ Employed = 2,
+
+ /// Есть в справочнике, но уволен из всех компаний, где числился.
+ Dismissed = 3,
+}
+
+///
+/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть
+/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны
+/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет
+/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат
+/// записан именно ФИО (колонка CandidateName) — временное решение, см.
+/// .
+///
+/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда
+/// это несколько записей одного и того же человека), но с тем же успехом могут
+/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя,
+/// поэтому все совпадения трактуются как записи одного человека.
+///
+internal sealed class EmployeeDirectorySnapshot
+{
+ private readonly ILookup _fullBaseByFio;
+ private readonly ILookup _contactsByFio;
+ private readonly IReadOnlyDictionary _fioByEmail;
+
+ public EmployeeDirectorySnapshot(
+ IReadOnlyList fullBase, IReadOnlyList 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);
+ }
+
+ ///
+ /// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его
+ /// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в
+ /// другой, продолжает работать — и напоминание ему по-прежнему нужно.
+ ///
+ public EmploymentStatus EmploymentOf(string fio)
+ {
+ var entries = _fullBaseByFio[Normalize(fio)].ToList();
+ if (entries.Count == 0) return EmploymentStatus.NotFound;
+
+ return entries.All(e => e.DismissalDate is not null)
+ ? EmploymentStatus.Dismissed
+ : EmploymentStatus.Employed;
+ }
+
+ ///
+ /// Первый непустой email среди записей этого ФИО. У одного человека записей
+ /// несколько (по компании на каждую), а email у него один и тот же — но
+ /// заполнен он может быть не в каждой записи.
+ ///
+ public string? EmailOf(string fio) =>
+ _contactsByFio[Normalize(fio)]
+ .Select(e => e.Email)
+ .FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
+
+ /// ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).
+ public string? NameByEmail(string email) =>
+ _fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
+
+ ///
+ /// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
+ /// регистра и лишних пробелов между словами (в живых данных встречаются и
+ /// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All).
+ ///
+ private static string Normalize(string fio) =>
+ string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs
new file mode 100644
index 0000000..20ac186
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/HireRow.cs
@@ -0,0 +1,46 @@
+using System.Text.Json;
+
+namespace Tnsu.ApprovalRoute.Application.Reminders;
+
+///
+/// Строка списка «Прием на работу — данные», разобранная из ответа SharePoint.
+/// Имена колонок — как в request-portal (provisioning.json формы RecruitingHire).
+///
+internal sealed record HireRow(
+ int Id,
+ string Status,
+ string? CandidateName,
+ DateTimeOffset? HireDate,
+ string? ProbationPeriod,
+ string? RequestUniqueCode)
+{
+ public static HireRow? TryParse(JsonElement item)
+ {
+ if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) return null;
+
+ return new HireRow(
+ id,
+ GetString(item, "RequestStatus") ?? string.Empty,
+ GetString(item, "CandidateName"),
+ TryGetDate(item, "HireDate"),
+ GetString(item, "ProbationPeriod"),
+ GetString(item, "RequestUniqueCode"));
+ }
+
+ private static string? GetString(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) &&
+ v.ValueKind == JsonValueKind.String &&
+ DateTimeOffset.TryParse(v.GetString(), out var parsed)
+ ? parsed
+ : null;
+}
+
+/// Статусы списка данных («… — данные», колонка RequestStatus) — считаем приём завершённым по ним.
+internal static class HireRequestStatuses
+{
+ public const string Approved = "Согласовано";
+ public const string Closed = "Закрыта";
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs
new file mode 100644
index 0000000..3a810ab
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ProbationEndingReminderDto.cs
@@ -0,0 +1,24 @@
+namespace Tnsu.ApprovalRoute.Application.Reminders;
+
+///
+/// Email руководителя принимаемого сотрудника — кому адресовано напоминание.
+/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение
+/// под вторым именем.
+///
+/// Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).
+/// ФИО принимаемого сотрудника (колонка CandidateName списка данных).
+/// Текст испытательного срока (колонка ProbationPeriod).
+/// Дата приёма (колонка HireDate).
+/// Дата окончания испытательного срока (HireDate + срок).
+/// Сколько дней осталось до конца испытательного срока.
+public sealed record ProbationEndingReminderDto(
+ string ReceiverEmail,
+ string CandidateEmail,
+ string CandidateName,
+ string? ProbationPeriod,
+ DateTimeOffset HireDate,
+ DateTimeOffset ProbationEndsAt,
+ int DaysUntilProbationEnds,
+ string Url,
+ string? ReceiverName = null)
+ : ReminderDto(ReceiverEmail, ReceiverName, Url);
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs
index 73f808a..3563423 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderDto.cs
@@ -1,27 +1,32 @@
+using System.Text.Json.Serialization;
+
namespace Tnsu.ApprovalRoute.Application.Reminders;
-/// Согласующий/исполнитель из строки списка — кому напоминать.
-///
-/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
-/// Выходные не учитываются — считаются календарные часы.
+/// Email того, кому адресовано напоминание.
+///
+/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
+/// в старой строке списка без заполненного Title персонального поля) — тогда null.
///
-/// Ссылка на заявку, требующую решения.
-///
-/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
-/// наследованием (шаг перекрывает тип заявки).
+///
+/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
+/// потому что не константна: requestId и партиция списка (server-relative url,
+/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
+/// дублировать эту логику снаружи незачем.
///
-/// Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).
-///
-/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
-/// заявки его нет или строка данных не найдена.
-///
-public sealed record ReminderDto(
- string Email,
- int HoursOverdue,
- string Url,
- bool AskOverdueReason,
- int RequestTypeId,
- string RequestTypeName,
- int RequestId,
- string Status,
- string? RequestUniqueCode);
+///
+/// Общая часть двух видов напоминаний — и
+/// . Категории заявки (RequestTypeId,
+/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
+/// (Power Automate ссылается на неё, работая со списком согласования конкретного
+/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
+///
+/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
+/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
+/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
+/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
+/// со значением "ApprovalOverdue"/"ProbationEnding".
+///
+[JsonPolymorphic]
+[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
+[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
+public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs
new file mode 100644
index 0000000..33085df
--- /dev/null
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs
@@ -0,0 +1,15 @@
+namespace Tnsu.ApprovalRoute.Application.Reminders;
+
+///
+/// Какой вид напоминаний запросить у .
+///
+/// («вид не указан») трактуется как :
+/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и
+/// вызов без явного выбора обязан работать как раньше.
+///
+public enum ReminderFeed
+{
+ None = 0,
+ ApprovalOverdue = 1,
+ ProbationEnding = 2,
+}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
index 6808b87..f38ffd4 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
@@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.Reminders;
@@ -22,6 +23,8 @@ public sealed class ReminderService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
+ ITnsuPortalClient portalClient,
+ TimeProvider timeProvider,
IOptions options,
ILogger logger)
{
@@ -30,6 +33,32 @@ public sealed class ReminderService(
private const string ActiveStatusFilter =
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
+ ///
+ /// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
+ /// это был единственный вид напоминаний, и без явного выбора им и остаётся.
+ ///
+ public Task>> GetRemindersAsync(CancellationToken cancellationToken) =>
+ GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken);
+
+ ///
+ /// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей
+ /// стороны. Момент «сейчас» берётся здесь же, из , а не
+ /// пробрасывается вызывающей стороной — во всех проде это TimeProvider.System
+ /// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/
+ /// GetProbationEndingAsync по-прежнему принимают now явным параметром: так
+ /// их сценарии остаются детерминированными без своего TimeProvider на каждый тест.
+ ///
+ public Task>> GetRemindersAsync(
+ ReminderFeed feed, CancellationToken cancellationToken)
+ {
+ var now = timeProvider.GetUtcNow();
+ return feed switch
+ {
+ ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
+ _ => GetOverdueAsync(now, cancellationToken),
+ };
+ }
+
public async Task>> GetOverdueAsync(
DateTimeOffset now, CancellationToken cancellationToken)
{
@@ -176,9 +205,17 @@ public sealed class ReminderService(
// ДО появления колонки (см. ApprovalRow) или если request-portal
// почему-то не проставил её при активации. Без даты старта SLA не
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
+ //
+ // RequestTypeId — всегда, чтобы искать TicketId в правильном типе
+ // заявок. RequestUniqueCode — то, по чему заявку реально ищут в
+ // портале, добавляется, только если он уже есть (строка данных
+ // могла ещё не появиться или заявка старше появления колонки).
+ var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
+ ? $"requestTypeId={type.Id}, code={code}"
+ : $"requestTypeId={type.Id}";
logger.LogWarning(
- "Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
- row.Id, row.TicketId);
+ "Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
+ row.Id, row.TicketId, identifier);
continue;
}
@@ -188,16 +225,17 @@ public sealed class ReminderService(
var deadline = activatedAt.AddHours(slaHours);
if (deadline >= now) continue;
- reminders.Add(new ReminderDto(
+ reminders.Add(new ApprovalOverdueReminderDto(
row.ApproverEmail!,
(int)Math.Floor((now - deadline).TotalHours),
- BuildUrl(type, row, listId),
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
type.Id,
type.Name,
row.TicketId,
row.Status,
- uniqueCodes.GetValueOrDefault(row.TicketId)));
+ uniqueCodes.GetValueOrDefault(row.TicketId),
+ BuildUrl(type, row, listId),
+ row.ApproverName));
}
return reminders;
@@ -234,7 +272,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> ReadRowsAsync(
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
@@ -273,4 +311,190 @@ public sealed class ReminderService(
return result;
}
+
+ ///
+ /// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
+ ///
+ /// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
+ /// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
+ /// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
+ /// портале, и напоминание уходило бы на день раньше срока.
+ ///
+ /// Константой, а не настройкой: смещение фиксировано для всей организации и не
+ /// меняется от окружения (перехода на летнее время нет).
+ ///
+ private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
+
+ private static readonly IReadOnlyDictionary ProbationMonthsByOption =
+ new Dictionary
+ {
+ ["1 месяц"] = 1,
+ ["2 месяца"] = 2,
+ ["3 месяца"] = 3,
+ ["6 месяцев"] = 6,
+ };
+
+ ///
+ /// Кому скоро выходить из испытательного срока — по заявкам «Заявка на приём»
+ /// (recruiting-hire). В отличие от НЕ завязано на
+ /// SlaHours типа заявки: это отдельный вид напоминаний со своим триггером
+ /// (дата приёма + испытательный срок), а не просрочка шага согласования.
+ ///
+ public async Task>> GetProbationEndingAsync(
+ DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ var type = await db.RequestTypes.AsNoTracking()
+ .FirstOrDefaultAsync(
+ x => x.PageUrl == options.Value.RecruitingHirePageUrl && x.ListId != null && x.SiteUrl != null,
+ cancellationToken);
+
+ if (type is null) return Result.Ok>([]);
+
+ var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
+ var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
+
+ // Испытательный срок — не больше 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 нет, а нужны
+ // сразу и дата увольнения (только в FullBase), и email (только в All).
+ var directory = new EmployeeDirectorySnapshot(
+ await portalClient.GetFullBaseAsync(cancellationToken),
+ await portalClient.GetAllEmployeesAsync(cancellationToken));
+
+ var reminders = new List();
+ foreach (var (listId, number) in partitions)
+ {
+ try
+ {
+ reminders.AddRange(
+ await GetProbationEndingForPartitionAsync(type, listId, directory, now, cancellationToken));
+ }
+ catch (Exception e)
+ {
+ logger.LogError(
+ e, "Напоминания об испытательном сроке по списку {ListId} (партиция №{Number}) не собраны",
+ listId, number);
+ }
+ }
+
+ return Result.Ok>(reminders);
+ }
+
+ private const string HireRowSelect =
+ "$select=Id,RequestStatus,CandidateName,HireDate,ProbationPeriod,RequestUniqueCode&$top=5000";
+
+ private async Task> GetProbationEndingForPartitionAsync(
+ RequestType type, string listId, EmployeeDirectorySnapshot directory,
+ DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ var siteUrl = type.SiteUrl!;
+
+ 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}')" +
+ $" 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();
+
+ var reminders = new List();
+ foreach (var row in rows)
+ {
+ var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken);
+ if (reminder is not null) reminders.Add(reminder);
+ }
+
+ return reminders;
+ }
+
+ ///
+ /// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний —
+ /// константа из настроек (см. ProbationReminderUrl), а поля самой заявки
+ /// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят.
+ ///
+ private async Task TryBuildProbationReminderAsync(
+ HireRow row, EmployeeDirectorySnapshot directory,
+ DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(row.CandidateName) || row.HireDate is not { } hireDate) return null;
+
+ if (!ProbationMonthsByOption.TryGetValue(row.ProbationPeriod ?? string.Empty, out var months))
+ return null; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
+
+ // Обе даты — в местном поясе (см. 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();
+
+ // Перечислены оба «продолжающих» исхода явно, а «пропустить» — это default:
+ // так любое НЕ подтверждённое трудоустройство (в том числе EmploymentStatus.None,
+ // если он когда-нибудь сюда доедет) приводит к пропуску с предупреждением, а не
+ // молча считается «работает».
+ switch (directory.EmploymentOf(candidateName))
+ {
+ case EmploymentStatus.Employed:
+ break;
+ case EmploymentStatus.Dismissed:
+ return null; // уже уволен — напоминать не о чем.
+ default:
+ logger.LogWarning(
+ "Заявка на приём {RequestId}: кандидат «{Fio}» не найден в /Employee/FullBase — статус трудоустройства не подтверждён, напоминание пропущено",
+ row.Id, candidateName);
+ return null;
+ }
+
+ var candidateEmail = directory.EmailOf(candidateName);
+ if (string.IsNullOrWhiteSpace(candidateEmail))
+ {
+ logger.LogWarning(
+ "Заявка на приём {RequestId}: у кандидата «{Fio}» не найден email в справочнике — напоминание пропущено",
+ row.Id, candidateName);
+ return null;
+ }
+
+ var managerEmail = await portalClient.GetManagerAsync(candidateEmail, "chief", cancellationToken);
+ if (string.IsNullOrWhiteSpace(managerEmail))
+ {
+ logger.LogWarning(
+ "Заявка на приём {RequestId}: у кандидата «{Fio}» не найден руководитель — напоминание пропущено",
+ row.Id, candidateName);
+ return null;
+ }
+
+ return new ProbationEndingReminderDto(
+ ReceiverEmail: managerEmail,
+ CandidateEmail: candidateEmail,
+ CandidateName: candidateName,
+ ProbationPeriod: row.ProbationPeriod,
+ // Тоже в местном поясе — иначе в письме дата приёма была бы на сутки
+ // раньше той, от которой посчитан ProbationEndsAt рядом.
+ HireDate: hireDateLocal,
+ ProbationEndsAt: probationEndsAt,
+ DaysUntilProbationEnds: daysLeft,
+ // Не ссылка на саму заявку на приём (она уже закрыта, решать по ней
+ // нечего) — константный адрес раздела, где руководитель заводит новую
+ // заявку по итогам испытательного срока. См. ProbationReminderUrl.
+ Url: options.Value.ProbationReminderUrl,
+ ReceiverName: directory.NameByEmail(managerEmail));
+ }
}
diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs
index 2776b5e..51352da 100644
--- a/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/RemindersOptions.cs
@@ -24,4 +24,35 @@ public sealed class RemindersOptions
///
public string RequestUrlTemplate { get; set; } =
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
+
+ ///
+ /// PageUrl типа заявки «Заявка на приём» (см. request-portal, forms/RecruitingHire/model.ts,
+ /// FORM_ID) — по нему ищется RequestType для напоминаний об окончании испытательного срока.
+ /// Не завязано на SlaHours: этот вид напоминаний не про просрочку шага согласования.
+ ///
+ public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
+
+ ///
+ /// За сколько дней до конца испытательного срока присылать напоминание — неделя,
+ /// чтобы у руководителя было время подготовить решение по итогам срока.
+ ///
+ /// Дни КАЛЕНДАРНЫЕ, не рабочие: напоминание строится ровно в тот день, когда до
+ /// конца срока остаётся столько дней, и попади он на выходной — уйдёт в выходной.
+ ///
+ public int ProbationReminderDaysBeforeEnd { get; set; } = 7;
+
+ ///
+ /// Куда ведёт ссылка в напоминании об окончании испытательного срока — на
+ /// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от
+ /// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение
+ /// по итогам испытательного срока), а не открыть уже закрытую старую.
+ ///
+ /// Поэтому ссылка константная, без подстановок: адрес конкретной формы в
+ /// портал вообще не передать — параметр rptype без rpid/rpsource
+ /// игнорируется, и маршрут сваливается в раздел «Подать заявку» (см.
+ /// parseRoute в request-portal/shared/routing.ts). Отдельная настройка, а не
+ /// хардкод, — чтобы адрес правился конфигурацией.
+ ///
+ public string ProbationReminderUrl { get; set; } =
+ "https://tnsukz.sharepoint.com/sites/requests?rpnav=new";
}
diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs
index 9b008b3..eb41027 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs
@@ -35,7 +35,7 @@ public sealed record CreateTemplateRouteStepRequest(
RouteStepStatus Status,
bool AllowReassignToOtherApprovers,
IReadOnlyList? Conditions,
- ConditionsLogic ConditionsLogic = ConditionsLogic.And,
+ ConditionsLogic ConditionsLogic,
int? SlaHours = null,
bool? AskOverdueReason = null);
@@ -48,7 +48,7 @@ public sealed record UpdateTemplateRouteStepRequest(
RouteStepStatus Status,
bool AllowReassignToOtherApprovers,
IReadOnlyList? Conditions,
- ConditionsLogic ConditionsLogic = ConditionsLogic.And,
+ ConditionsLogic ConditionsLogic,
int? SlaHours = null,
bool? AskOverdueReason = null);
diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs
index 2a5d42e..e9c5ab7 100644
--- a/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs
+++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepValidators.cs
@@ -8,6 +8,9 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
public CreateTemplateRouteStepRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
+ RuleFor(x => x.ConditionsLogic)
+ .NotEqual(ConditionsLogic.None)
+ .WithMessage("Логика объединения условий обязательна: And или Or.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -28,6 +31,9 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
public UpdateTemplateRouteStepRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
+ RuleFor(x => x.ConditionsLogic)
+ .NotEqual(ConditionsLogic.None)
+ .WithMessage("Логика объединения условий обязательна: And или Or.");
RuleFor(x => x)
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
@@ -43,6 +49,35 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
}
}
+///
+/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно:
+/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО
+/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest).
+///
+public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator
+{
+ public PatchTemplateRouteStepRequestValidator()
+ {
+ When(x => x.Status is not null, () =>
+ RuleFor(x => x.Status)
+ .NotEqual(RouteStepStatus.None)
+ .WithMessage("Статус обязателен: Approval или Execution."));
+ When(x => x.ConditionsLogic is not null, () =>
+ RuleFor(x => x.ConditionsLogic)
+ .NotEqual(ConditionsLogic.None)
+ .WithMessage("Логика объединения условий обязательна: And или Or."));
+ RuleFor(x => x)
+ .Must(x => TemplateRouteStepFields.AreConditionsValid(x.Conditions))
+ .WithMessage("В каждом условии (field_name, operation, value) все три поля обязательны.");
+ // Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
+ // ноль допустим — это осознанное «напоминать сразу».
+ RuleFor(x => x.SlaHours)
+ .GreaterThanOrEqualTo(0)
+ .When(x => x.SlaHours is not null)
+ .WithMessage("SLA в часах не может быть отрицательным.");
+ }
+}
+
internal static class TemplateRouteStepFields
{
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs
index 1f37402..a0ba7f3 100644
--- a/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs
+++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/ConditionsLogic.cs
@@ -6,9 +6,21 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
///
public enum ConditionsLogic
{
+ ///
+ /// Логика не задана — НЕвалидное значение (см. валидаторы шага), а не «как И».
+ /// Существует только как значение по умолчанию (default(ConditionsLogic)):
+ /// шаг с такой логикой не должен ни создаваться, ни сохраняться.
+ ///
+ /// Именно поэтому у самой сущности и у запросов на создание/обновление
+ /// значение по умолчанию задано явно — ConditionsLogic.And
+ /// (см. TemplateRouteStep.ConditionsLogic и *TemplateRouteStepRequest):
+ /// пропущенное в запросе поле по-прежнему означает «И», как и раньше.
+ ///
+ None = 0,
+
/// Шаг появляется, только если выполняются ВСЕ условия.
- And,
+ And = 1,
/// Шаг появляется, если выполняется ХОТЯ БЫ ОДНО условие.
- Or
+ Or = 2
}
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs
index 6f1ade9..4b931e2 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs
@@ -27,11 +27,21 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
+ public Task> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
+ GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
+
public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
() => inner.GetManagerAsync(email, managerType, cancellationToken));
+ // Базы целиком (/Employee/All выше и /Employee/FullBase ниже) кешируем по
+ // фиксированному ключу — это не запросы «по чему-то», а единственные на весь
+ // процесс снимки справочника; TTL тот же (5 минут), что и у остальных
+ // обращений к api.tnsu.kz.
+ public Task> GetFullBaseAsync(CancellationToken cancellationToken) =>
+ GetOrAddAsync("emp:fullbase", () => inner.GetFullBaseAsync(cancellationToken));
+
public Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
GetOrAddAsync(
$"projexec:{projectId}",
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs
index c679c13..4a56e4e 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs
@@ -32,6 +32,18 @@ public sealed class HttpTnsuPortalClient(
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
}
+ public async Task> GetAllEmployeesAsync(CancellationToken cancellationToken)
+ {
+ var employees = await GetAsync>("/Employee/All", cancellationToken);
+ if (employees is null)
+ return [];
+
+ return employees
+ .Where(e => !string.IsNullOrWhiteSpace(e.Fio))
+ .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty))
+ .ToList();
+ }
+
public async Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
{
var manager = await GetAsync(
@@ -40,6 +52,18 @@ public sealed class HttpTnsuPortalClient(
return manager?.ManagerEmail;
}
+ public async Task> GetFullBaseAsync(CancellationToken cancellationToken)
+ {
+ var employees = await GetAsync>("/Employee/FullBase", cancellationToken);
+ if (employees is null)
+ return [];
+
+ return employees
+ .Where(e => !string.IsNullOrWhiteSpace(e.Fio))
+ .Select(e => new EmployeeFullBaseInfo(e.Id, e.Fio!, e.Company ?? string.Empty, e.DismissalDate))
+ .ToList();
+ }
+
public async Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
{
var project = await GetAsync($"/Project/{projectId}", cancellationToken);
@@ -100,6 +124,12 @@ public sealed class HttpTnsuPortalClient(
private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
+ private sealed record FullBaseResponse(
+ [property: JsonPropertyName("id")] Guid? Id,
+ [property: JsonPropertyName("fio")] string? Fio,
+ [property: JsonPropertyName("company")] string? Company,
+ [property: JsonPropertyName("dismissalDate")] DateTimeOffset? DismissalDate);
+
private sealed record ProjectResponse(
[property: JsonPropertyName("projectManager")] string? ProjectManager,
[property: JsonPropertyName("planner")] string? Planner,
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs
index 731a898..4da2118 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs
@@ -11,8 +11,24 @@ public interface ITnsuPortalClient
Task GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
+ ///
+ /// Весь справочник сотрудников с email — /Employee/All. Нужен там, где известно
+ /// только ФИО, а email ещё нет: точечного поиска по ФИО в api.tnsu.kz нет вовсе
+ /// (эндпоинта /Employee/ByFio не существует), поэтому приходится тянуть базу
+ /// целиком и искать в ней локально. ВРЕМЕННОЕ решение — как только на стороне
+ /// api.tnsu.kz появится поиск по ФИО, этот вызов заменится точечным.
+ ///
+ Task> GetAllEmployeesAsync(CancellationToken cancellationToken);
+
Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
+ ///
+ /// Весь справочник сотрудников — /Employee/FullBase. Единственный источник, где
+ /// есть дата увольнения (DismissalDate): нужен, чтобы не напоминать про
+ /// испытательный срок сотрудника, который уже уволен.
+ ///
+ Task> GetFullBaseAsync(CancellationToken cancellationToken);
+
Task> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
/// Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.
diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs
index bae25ac..7940891 100644
--- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs
+++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs
@@ -17,3 +17,17 @@ public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
/// Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
+
+///
+/// Запись из /Employee/FullBase — весь справочник сотрудников, но БЕЗ email (в
+/// отличие от /Employee/All). Нужна только ради DismissalDate: единственное место,
+/// где отдаётся дата увольнения.
+///
+public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, DateTimeOffset? DismissalDate);
+
+///
+/// Запись из /Employee/All — весь справочник сотрудников с email, но БЕЗ даты
+/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
+/// по — он один и тот же в обоих ответах.
+///
+public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company);
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs
new file mode 100644
index 0000000..8b6369b
--- /dev/null
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderDtoTests.cs
@@ -0,0 +1,32 @@
+using Shouldly;
+using Tnsu.ApprovalRoute.Application.Reminders;
+
+namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
+
+///
+/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами
+/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и
+/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см.
+/// ReminderDto) — так переход на новый контракт можно делать постепенно, не
+/// ломая существующие Power Automate-флоу разом.
+///
+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);
+ }
+}
diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
index 778b2e9..e1f4d0a 100644
--- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
+++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@@ -9,6 +10,7 @@ using Tnsu.ApprovalRoute.Application.Reminders;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
+using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
@@ -29,8 +31,28 @@ public sealed class ReminderServiceTests : IClassFixture now;
+ }
+
+ private static readonly TimeProvider Clock = new FixedTimeProvider(Now);
+
private readonly ServiceProvider _services;
private readonly RequestTypeService _requestTypeService;
@@ -53,6 +75,23 @@ public sealed class ReminderServiceTests : IClassFixture CreateHireTypeAsync(string name)
+ {
+ var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
+ var pageUrl = $"/{name.Replace(" ", string.Empty)}";
+ await _requestTypeService.CreateAsync(
+ new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
+ CancellationToken.None);
+
+ return (listUrl, pageUrl);
+ }
+
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
private static object Row(
@@ -104,6 +143,8 @@ public sealed class ReminderServiceTests : IClassFixture(),
sharePoint.Object,
_services.GetRequiredService(),
+ Mock.Of(),
+ Clock,
Options.Create(new RemindersOptions()),
NullLogger.Instance);
}
@@ -119,12 +160,38 @@ public sealed class ReminderServiceTests : IClassFixture();
reminder.Email.ShouldBe("approver@tnsu.kz");
reminder.HoursOverdue.ShouldBe(24);
reminder.RequestId.ShouldBe(1);
}
+ [Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")]
+ public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue()
+ {
+ var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24);
+ var service = BuildService(listUrl,
+ [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
+
+ var result = await service.GetRemindersAsync(CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Value.ShouldHaveSingleItem().ShouldBeOfType().HoursOverdue.ShouldBe(24);
+ }
+
+ [Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")]
+ public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue()
+ {
+ var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24);
+ var service = BuildService(listUrl,
+ [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
+
+ var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None);
+
+ result.IsSuccess.ShouldBeTrue();
+ result.Value.ShouldBeEmpty();
+ }
+
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
public async Task WithinSla_ReturnsNothing()
{
@@ -153,7 +220,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(2);
}
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
@@ -179,7 +246,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5);
}
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
@@ -199,7 +266,7 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(5);
}
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
@@ -211,7 +278,7 @@ public sealed class ReminderServiceTests : IClassFixture().AskOverdueReason.ShouldBeTrue();
}
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
@@ -256,6 +323,300 @@ public sealed class ReminderServiceTests : IClassFixture().Email.ShouldBe("approver@tnsu.kz");
+ }
+
+ private static object HireDataRow(
+ int id, string status, string? candidateName, string? hireDate, string? probationPeriod, string? code = null) =>
+ new
+ {
+ Id = id,
+ RequestStatus = status,
+ CandidateName = candidateName,
+ HireDate = hireDate,
+ ProbationPeriod = probationPeriod,
+ RequestUniqueCode = code
+ };
+
+ private ReminderService BuildProbationService(
+ string pageUrl, string listUrl, IReadOnlyList