Расширение reminders #19
@@ -22,5 +22,5 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||||
(await service.GetOverdueAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
(await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, если у
|
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||||
/// заявки его нет или строка данных не найдена.
|
/// заявки его нет или строка данных не найдена.
|
||||||
/// </param>
|
/// </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(
|
public sealed record ReminderDto(
|
||||||
string Email,
|
string Email,
|
||||||
int HoursOverdue,
|
int HoursOverdue,
|
||||||
@@ -24,4 +41,12 @@ public sealed record ReminderDto(
|
|||||||
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);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
|||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ public sealed class ReminderService(
|
|||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
ISharePointClient sharePoint,
|
ISharePointClient sharePoint,
|
||||||
ListPartitionRegistry partitionRegistry,
|
ListPartitionRegistry partitionRegistry,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
IOptions<RemindersOptions> options,
|
IOptions<RemindersOptions> options,
|
||||||
ILogger<ReminderService> logger)
|
ILogger<ReminderService> logger)
|
||||||
{
|
{
|
||||||
@@ -30,6 +32,19 @@ public sealed class ReminderService(
|
|||||||
private const string ActiveStatusFilter =
|
private const string ActiveStatusFilter =
|
||||||
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
$"$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(
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||||
DateTimeOffset now, CancellationToken cancellationToken)
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -197,7 +212,8 @@ public sealed class ReminderService(
|
|||||||
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;
|
||||||
@@ -273,4 +289,163 @@ public sealed class ReminderService(
|
|||||||
|
|
||||||
return result;
|
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>
|
/// </summary>
|
||||||
public string RequestUrlTemplate { get; set; } =
|
public string RequestUrlTemplate { get; set; } =
|
||||||
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}&rplist={listId}";
|
"{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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,21 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC
|
|||||||
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
|
$"emp:position:{position.ToLowerInvariant()}|{company.ToLowerInvariant()}",
|
||||||
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
|
() => inner.GetEmployeeByPositionAsync(position, company, cancellationToken));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken));
|
||||||
|
|
||||||
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
public Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
$"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}",
|
||||||
() => inner.GetManagerAsync(email, managerType, cancellationToken));
|
() => inner.GetManagerAsync(email, managerType, cancellationToken));
|
||||||
|
|
||||||
|
// Базы целиком (/Employee/All выше и /Employee/FullBase ниже) кешируем по
|
||||||
|
// фиксированному ключу — это не запросы «по чему-то», а единственные на весь
|
||||||
|
// процесс снимки справочника; TTL тот же (5 минут), что и у остальных
|
||||||
|
// обращений к api.tnsu.kz.
|
||||||
|
public Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOrAddAsync("emp:fullbase", () => inner.GetFullBaseAsync(cancellationToken));
|
||||||
|
|
||||||
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) =>
|
||||||
GetOrAddAsync(
|
GetOrAddAsync(
|
||||||
$"projexec:{projectId}",
|
$"projexec:{projectId}",
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
|
return new EmployeeLookupResult(employee is not null, employee?.Email, employee?.Fio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employees = await GetAsync<List<EmployeeResponse>>("/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<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var manager = await GetAsync<ManagerResponse>(
|
var manager = await GetAsync<ManagerResponse>(
|
||||||
@@ -40,6 +52,18 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
return manager?.ManagerEmail;
|
return manager?.ManagerEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employees = await GetAsync<List<FullBaseResponse>>("/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<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
||||||
@@ -100,6 +124,12 @@ public sealed class HttpTnsuPortalClient(
|
|||||||
private sealed record ManagerResponse(
|
private sealed record ManagerResponse(
|
||||||
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
[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(
|
private sealed record ProjectResponse(
|
||||||
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
||||||
[property: JsonPropertyName("planner")] string? Planner,
|
[property: JsonPropertyName("planner")] string? Planner,
|
||||||
|
|||||||
@@ -11,8 +11,24 @@ public interface ITnsuPortalClient
|
|||||||
|
|
||||||
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
Task<EmployeeLookupResult> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Весь справочник сотрудников с email — /Employee/All. Нужен там, где известно
|
||||||
|
/// только ФИО, а email ещё нет: точечного поиска по ФИО в api.tnsu.kz нет вовсе
|
||||||
|
/// (эндпоинта /Employee/ByFio не существует), поэтому приходится тянуть базу
|
||||||
|
/// целиком и искать в ней локально. ВРЕМЕННОЕ решение — как только на стороне
|
||||||
|
/// api.tnsu.kz появится поиск по ФИО, этот вызов заменится точечным.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<EmployeeContactInfo>> GetAllEmployeesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Весь справочник сотрудников — /Employee/FullBase. Единственный источник, где
|
||||||
|
/// есть дата увольнения (DismissalDate): нужен, чтобы не напоминать про
|
||||||
|
/// испытательный срок сотрудника, который уже уволен.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<EmployeeFullBaseInfo>> GetFullBaseAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
|
/// <summary>Email руководителя указанного отдела (для HeadType.DepartmentHead) — null, если не найден.</summary>
|
||||||
|
|||||||
@@ -17,3 +17,17 @@ public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
|||||||
|
|
||||||
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
|
/// <summary>Найден ли сотрудник вообще — отдельно от того, заполнен ли у него email в портале (это разные проблемы).</summary>
|
||||||
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
|
public sealed record EmployeeLookupResult(bool Found, string? Email, string? Fio);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запись из /Employee/FullBase — весь справочник сотрудников, но БЕЗ email (в
|
||||||
|
/// отличие от /Employee/All). Нужна только ради DismissalDate: единственное место,
|
||||||
|
/// где отдаётся дата увольнения.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, DateTimeOffset? DismissalDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Запись из /Employee/All — весь справочник сотрудников с email, но БЕЗ даты
|
||||||
|
/// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно
|
||||||
|
/// по <paramref name="Id"/> — он один и тот же в обоих ответах.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company);
|
||||||
|
|||||||
+214
@@ -9,6 +9,7 @@ using Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
@@ -53,6 +54,23 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
return listUrl;
|
return listUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageUrl отдельным параметром (а не /{name}, как в CreateTypeWithSlaAsync) —
|
||||||
|
// GetProbationEndingAsync ищет тип НЕ по SLA, а именно по PageUrl (см.
|
||||||
|
// RemindersOptions.RecruitingHirePageUrl), и у каждого теста здесь свой
|
||||||
|
// уникальный PageUrl, передаваемый в опции сервиса той же строкой: так тесты
|
||||||
|
// не видят чужие «recruiting-hire»-записи, случайно оставшиеся от других
|
||||||
|
// тестов в общей БД фикстуры.
|
||||||
|
private async Task<(string ListUrl, string PageUrl)> 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 JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||||
|
|
||||||
private static object Row(
|
private static object Row(
|
||||||
@@ -104,6 +122,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
|
Mock.Of<ITnsuPortalClient>(),
|
||||||
Options.Create(new RemindersOptions()),
|
Options.Create(new RemindersOptions()),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
@@ -123,6 +142,7 @@ 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 = "Срок ещё не вышел — напоминания нет")]
|
||||||
@@ -258,4 +278,198 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz");
|
result.Value.ShouldHaveSingleItem().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<object> rows, Mock<ITnsuPortalClient> portalClient,
|
||||||
|
int daysBeforeEnd = 3)
|
||||||
|
{
|
||||||
|
const string hireDataListTitle = "Прием на работу тест — данные";
|
||||||
|
|
||||||
|
var sharePoint = new Mock<ISharePointClient>();
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(hireDataListTitle);
|
||||||
|
|
||||||
|
// GetProbationEndingAsync читает только «Согласовано»/«Закрыта» — SharePoint
|
||||||
|
// фильтрует это сам ($filter в запросе), мок повторяет ту же фильтрацию.
|
||||||
|
var closedRows = rows
|
||||||
|
.Where(r => (string)r.GetType().GetProperty("RequestStatus")!.GetValue(r)! is "Согласовано" or "Закрыта")
|
||||||
|
.ToList();
|
||||||
|
sharePoint
|
||||||
|
.Setup(x => x.GetItemsByListTitleAsync(
|
||||||
|
SiteUrl, hireDataListTitle, It.Is<string>(q => q.Contains("RequestStatus eq")), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(closedRows.Select(Json).ToList());
|
||||||
|
|
||||||
|
return new ReminderService(
|
||||||
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
|
sharePoint.Object,
|
||||||
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
|
portalClient.Object,
|
||||||
|
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }),
|
||||||
|
NullLogger<ReminderService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<ITnsuPortalClient> BuildPortalClientMock(
|
||||||
|
string candidateFio, DateTimeOffset? dismissalDate, string? candidateEmail, string? managerEmail)
|
||||||
|
{
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new EmployeeFullBaseInfo(Guid.NewGuid(), candidateFio, "ТОО TANSU", dismissalDate)]);
|
||||||
|
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), candidateFio, candidateEmail, "ТОО TANSU")]);
|
||||||
|
|
||||||
|
if (candidateEmail is not null)
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync(candidateEmail, "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(managerEmail);
|
||||||
|
|
||||||
|
return portalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "До конца испытательного срока 3 дня — напоминание с email кандидата и его руководителя")]
|
||||||
|
public async Task ProbationEndingIn3Days_BuildsReminder()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation due");
|
||||||
|
// probationEndsAt = Now.Date + 3 дня = 2026-08-15; "2 месяца" от 2026-06-15.
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Иванов Иван Иванович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Иванов Иван Иванович", dismissalDate: null, candidateEmail: "candidate@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.IsSuccess.ShouldBeTrue();
|
||||||
|
var reminder = result.Value.ShouldHaveSingleItem();
|
||||||
|
reminder.Category.ShouldBe(ReminderCategory.ProbationEnding);
|
||||||
|
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz");
|
||||||
|
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
|
||||||
|
reminder.Email.ShouldBe("manager@tnsu.kz");
|
||||||
|
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
|
||||||
|
reminder.DaysUntilProbationEnds.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
|
||||||
|
public async Task DismissedCandidate_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation dismissed");
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Петров Пётр Петрович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Петров Пётр Петрович", dismissalDate: Now.AddDays(-1), candidateEmail: "petrov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сотрудник холдинга может числиться в нескольких компаниях сразу (см.
|
||||||
|
// /Employee/FullBase — по записи на компанию). Увольнение из одной из них не
|
||||||
|
// означает, что человек ушёл: пока хоть где-то дата увольнения не стоит, он
|
||||||
|
// работает, и напоминание ему по-прежнему нужно.
|
||||||
|
[Fact(DisplayName = "Уволен из одной компании, но работает в другой — напоминание есть")]
|
||||||
|
public async Task DismissedFromOneCompanyOnly_StillBuildsReminder()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation partly dismissed");
|
||||||
|
const string fio = "Романов Роман Романович";
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(
|
||||||
|
[
|
||||||
|
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО АСТЕЛ-К", Now.AddDays(-10)),
|
||||||
|
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО TANSU Construction", null)
|
||||||
|
]);
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), fio, "romanov@tnsu.kz", "ТОО TANSU Construction")]);
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync("romanov@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldHaveSingleItem().CandidateEmail.ShouldBe("romanov@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
|
||||||
|
// ломать сопоставление со справочником (см. EmployeeDirectorySnapshot.Normalize).
|
||||||
|
[Fact(DisplayName = "ФИО сопоставляется без учёта регистра и лишних пробелов")]
|
||||||
|
public async Task CandidateName_IsMatchedLoosely()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation fio normalize");
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", " волков ВОЛК волкович ", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Волков Волк Волкович", dismissalDate: null, candidateEmail: "volkov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldHaveSingleItem().CandidateEmail.ShouldBe("volkov@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
|
||||||
|
public async Task CandidateNotInFullBase_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation unknown");
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Сидоров Сидор Сидорович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||||
|
portalClient.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "До конца испытательного срока не ровно 3 дня — напоминания нет")]
|
||||||
|
public async Task NotExactlyThreeDaysLeft_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation not due");
|
||||||
|
// "1 месяц" от 2026-06-15 → срок кончился 2026-07-15, задолго до Now.
|
||||||
|
var rows = new[] { HireDataRow(1, "Закрыта", "Кузнецов Кузьма Кузьмич", "2026-06-15T00:00:00Z", "1 месяц") };
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Кузнецов Кузьма Кузьмич", dismissalDate: null, candidateEmail: "kuznecov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "«Без испытательного срока» — напоминания нет")]
|
||||||
|
public async Task NoProbationPeriod_IsSkipped()
|
||||||
|
{
|
||||||
|
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation none");
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
HireDataRow(1, "Закрыта", "Смирнов Смирн Смирнович", "2026-06-15T00:00:00Z", "Без испытательного срока")
|
||||||
|
};
|
||||||
|
var portalClient = BuildPortalClientMock(
|
||||||
|
"Смирнов Смирн Смирнович", dismissalDate: null, candidateEmail: "smirnov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
|
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||||||
|
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Value.ShouldBeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user