промежуточный коммит
Tests / frontend-test (pull_request) Successful in 47s
Tests / backend-test (pull_request) Successful in 1m34s

This commit is contained in:
gorbenko.pavel
2026-08-26 17:17:47 +05:00
parent 19a46b0df8
commit 874e41990c
12 changed files with 640 additions and 3 deletions
@@ -0,0 +1,74 @@
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.Reminders;
internal enum EmploymentStatus
{
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
NotFound,
/// <summary>Работает хотя бы в одной компании.</summary>
Employed,
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
Dismissed,
}
/// <summary>
/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть
/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны
/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет
/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат
/// записан именно ФИО (колонка CandidateName) — временное решение, см.
/// <see cref="ITnsuPortalClient.GetAllEmployeesAsync"/>.
///
/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда
/// это несколько записей одного и того же человека), но с тем же успехом могут
/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя,
/// поэтому все совпадения трактуются как записи одного человека.
/// </summary>
internal sealed class EmployeeDirectorySnapshot
{
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
public EmployeeDirectorySnapshot(
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
{
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
_contactsByFio = contacts.ToLookup(e => Normalize(e.Fio));
}
/// <summary>
/// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его
/// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в
/// другой, продолжает работать — и напоминание ему по-прежнему нужно.
/// </summary>
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;
}
/// <summary>
/// Первый непустой email среди записей этого ФИО. У одного человека записей
/// несколько (по компании на каждую), а email у него один и тот же — но
/// заполнен он может быть не в каждой записи.
/// </summary>
public string? EmailOf(string fio) =>
_contactsByFio[Normalize(fio)]
.Select(e => e.Email)
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
/// <summary>
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
/// регистра и лишних пробелов между словами (в живых данных встречаются и
/// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All).
/// </summary>
private static string Normalize(string fio) =>
string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
}
@@ -0,0 +1,46 @@
using System.Text.Json;
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Строка списка «Прием на работу — данные», разобранная из ответа SharePoint.
/// Имена колонок — как в request-portal (provisioning.json формы RecruitingHire).
/// </summary>
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;
}
/// <summary>Статусы списка данных («… — данные», колонка RequestStatus) — считаем приём завершённым по ним.</summary>
internal static class HireRequestStatuses
{
public const string Approved = "Согласовано";
public const string Closed = "Закрыта";
}
@@ -0,0 +1,23 @@
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,
}
@@ -15,6 +15,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
/// заявки его нет или строка данных не найдена.
/// </param>
/// <param name="Category">
/// Вид напоминания. Необязательный — параметр добавлен позже остальных, поэтому по
/// умолчанию <see cref="ReminderCategory.None"/> («не указана»): старые вызовы
/// конструктора и потребители, ничего не знающие о категориях, продолжают работать
/// как раньше.
/// </param>
/// <param name="ManagerEmail">
/// Только для <see cref="ReminderCategory.ProbationEnding"/> — email руководителя
/// принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync). Для
/// остальных категорий — null.
/// </param>
/// <param name="CandidateEmail">Только для ProbationEnding — email принимаемого сотрудника.</param>
/// <param name="CandidateName">Только для ProbationEnding — ФИО принимаемого сотрудника (колонка CandidateName).</param>
/// <param name="ProbationPeriod">Только для ProbationEnding — текст испытательного срока (колонка ProbationPeriod).</param>
/// <param name="HireDate">Только для ProbationEnding — дата приёма (колонка HireDate).</param>
/// <param name="ProbationEndsAt">Только для ProbationEnding — дата окончания испытательного срока (HireDate + срок).</param>
/// <param name="DaysUntilProbationEnds">Только для ProbationEnding — сколько дней осталось до конца испытательного срока.</param>
public sealed record ReminderDto(
string Email,
int HoursOverdue,
@@ -24,4 +41,12 @@ public sealed record ReminderDto(
string RequestTypeName,
int RequestId,
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);
@@ -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,7 @@ public sealed class ReminderService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
ITnsuPortalClient portalClient,
IOptions<RemindersOptions> options,
ILogger<ReminderService> logger)
{
@@ -30,6 +32,19 @@ public sealed class ReminderService(
private const string ActiveStatusFilter =
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
/// <summary>Оба вида напоминаний разом — то, что реально отдаёт контроллер.</summary>
public async Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
DateTimeOffset now, CancellationToken cancellationToken)
{
var overdue = await GetOverdueAsync(now, cancellationToken);
if (overdue.IsFailed) return overdue;
var probation = await GetProbationEndingAsync(now, cancellationToken);
if (probation.IsFailed) return probation;
return Result.Ok<IReadOnlyList<ReminderDto>>(overdue.Value.Concat(probation.Value).ToList());
}
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
DateTimeOffset now, CancellationToken cancellationToken)
{
@@ -197,7 +212,8 @@ public sealed class ReminderService(
type.Name,
row.TicketId,
row.Status,
uniqueCodes.GetValueOrDefault(row.TicketId)));
uniqueCodes.GetValueOrDefault(row.TicketId),
ReminderCategory.ApprovalOverdue));
}
return reminders;
@@ -273,4 +289,163 @@ public sealed class ReminderService(
return result;
}
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
new Dictionary<string, int>
{
["1 месяц"] = 1,
["2 месяца"] = 2,
["3 месяца"] = 3,
["6 месяцев"] = 6,
};
/// <summary>
/// Кому скоро выходить из испытательного срока — по заявкам «Заявка на приём»
/// (recruiting-hire). В отличие от <see cref="GetOverdueAsync"/> НЕ завязано на
/// SlaHours типа заявки: это отдельный вид напоминаний со своим триггером
/// (дата приёма + испытательный срок), а не просрочка шага согласования.
/// </summary>
public async Task<Result<IReadOnlyList<ReminderDto>>> 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<IReadOnlyList<ReminderDto>>([]);
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
var partitions = archived.Select(x => (x.ListId, x.Number)).Append((current.ListId, current.Number));
// Обе базы сотрудников читаются по одному разу на весь обход партиций (а не
// на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
// сразу и дата увольнения (только в FullBase), и email (только в All).
var directory = new EmployeeDirectorySnapshot(
await portalClient.GetFullBaseAsync(cancellationToken),
await portalClient.GetAllEmployeesAsync(cancellationToken));
var reminders = new List<ReminderDto>();
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<IReadOnlyList<ReminderDto>>(reminders);
}
private const string HireRowSelect =
"$select=Id,RequestStatus,CandidateName,HireDate,ProbationPeriod,RequestUniqueCode&$top=5000";
private async Task<IReadOnlyList<ReminderDto>> 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 [];
var filter =
$"$filter=RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}'";
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<ReminderDto>();
foreach (var row in rows)
{
var reminder = await TryBuildProbationReminderAsync(type, row, listId, directory, now, cancellationToken);
if (reminder is not null) reminders.Add(reminder);
}
return reminders;
}
private async Task<ReminderDto?> TryBuildProbationReminderAsync(
RequestType type, HireRow row, string listId, 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; // "Без испытательного срока", пусто или значение вне справочника — отсчитывать нечего.
var probationEndsAt = hireDate.AddMonths(months);
var daysLeft = (probationEndsAt.Date - now.Date).Days;
if (daysLeft != options.Value.ProbationReminderDaysBeforeEnd) return null;
var candidateName = row.CandidateName.Trim();
switch (directory.EmploymentOf(candidateName))
{
case EmploymentStatus.Dismissed:
return null; // уже уволен — напоминать не о чем.
case EmploymentStatus.NotFound:
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 ReminderDto(
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,
CandidateEmail: candidateEmail,
CandidateName: candidateName,
ProbationPeriod: row.ProbationPeriod,
HireDate: row.HireDate,
ProbationEndsAt: probationEndsAt,
DaysUntilProbationEnds: daysLeft);
}
/// <summary>
/// Ссылка на саму заявку (не на шаг согласования — этого вида напоминаний
/// понятие "фаза" не касается), в том же формате, что и BuildUrl.
/// </summary>
private string BuildProbationUrl(RequestType type, HireRow row, string listId) =>
options.Value.RequestUrlTemplate
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
.Replace("{pageUrl}", type.PageUrl)
.Replace("{requestId}", row.Id.ToString())
.Replace("{listId}", Uri.EscapeDataString(listId))
.Replace("{nav}", "approvals");
}
@@ -24,4 +24,14 @@ public sealed class RemindersOptions
/// </summary>
public string RequestUrlTemplate { get; set; } =
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
/// <summary>
/// PageUrl типа заявки «Заявка на приём» (см. request-portal, forms/RecruitingHire/model.ts,
/// FORM_ID) — по нему ищется RequestType для напоминаний об окончании испытательного срока.
/// Не завязано на SlaHours: этот вид напоминаний не про просрочку шага согласования.
/// </summary>
public string RecruitingHirePageUrl { get; set; } = "recruiting-hire";
/// <summary>За сколько дней до конца испытательного срока присылать напоминание.</summary>
public int ProbationReminderDaysBeforeEnd { get; set; } = 3;
}