Расширение reminders (#19)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/19
This commit was merged in pull request #19.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="Email">
|
||||
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
|
||||
/// именем ради обратной совместимости с потребителями, читавшими это поле до
|
||||
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
|
||||
/// </param>
|
||||
/// <param name="HoursOverdue">
|
||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||
/// Выходные не учитываются — считаются календарные часы.
|
||||
/// </param>
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||
/// наследованием (шаг перекрывает тип заявки).
|
||||
/// </param>
|
||||
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
|
||||
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
|
||||
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
|
||||
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
||||
/// <param name="RequestUniqueCode">
|
||||
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||
/// заявки его нет или строка данных не найдена.
|
||||
/// </param>
|
||||
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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
internal enum EmploymentStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
|
||||
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
|
||||
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
||||
NotFound = 1,
|
||||
|
||||
/// <summary>Работает хотя бы в одной компании.</summary>
|
||||
Employed = 2,
|
||||
|
||||
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
||||
Dismissed = 3,
|
||||
}
|
||||
|
||||
/// <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;
|
||||
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||||
|
||||
public EmployeeDirectorySnapshot(
|
||||
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> 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);
|
||||
}
|
||||
|
||||
/// <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>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||||
public string? NameByEmail(string email) =>
|
||||
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||||
|
||||
/// <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,24 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="ReceiverEmail">
|
||||
/// Email руководителя принимаемого сотрудника — кому адресовано напоминание.
|
||||
/// Отдельного ManagerEmail рядом нет намеренно: это было то же самое значение
|
||||
/// под вторым именем.
|
||||
/// </param>
|
||||
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
|
||||
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
|
||||
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
|
||||
/// <param name="HireDate">Дата приёма (колонка HireDate).</param>
|
||||
/// <param name="ProbationEndsAt">Дата окончания испытательного срока (HireDate + срок).</param>
|
||||
/// <param name="DaysUntilProbationEnds">Сколько дней осталось до конца испытательного срока.</param>
|
||||
public sealed record ProbationEndingReminderDto(
|
||||
string ReceiverEmail,
|
||||
string CandidateEmail,
|
||||
string CandidateName,
|
||||
string? ProbationPeriod,
|
||||
DateTimeOffset HireDate,
|
||||
DateTimeOffset ProbationEndsAt,
|
||||
int DaysUntilProbationEnds,
|
||||
string Url,
|
||||
string? ReceiverName = null)
|
||||
: ReminderDto(ReceiverEmail, ReceiverName, Url);
|
||||
@@ -1,27 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
||||
/// <param name="HoursOverdue">
|
||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||
/// Выходные не учитываются — считаются календарные часы.
|
||||
/// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
|
||||
/// <param name="ReceiverName">
|
||||
/// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
|
||||
/// в старой строке списка без заполненного Title персонального поля) — тогда null.
|
||||
/// </param>
|
||||
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||
/// наследованием (шаг перекрывает тип заявки).
|
||||
/// <param name="Url">
|
||||
/// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
|
||||
/// потому что не константна: requestId и партиция списка (server-relative url,
|
||||
/// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
|
||||
/// дублировать эту логику снаружи незачем.
|
||||
/// </param>
|
||||
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
|
||||
/// <param name="RequestUniqueCode">
|
||||
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
|
||||
/// заявки его нет или строка данных не найдена.
|
||||
/// </param>
|
||||
public sealed record ReminderDto(
|
||||
string Email,
|
||||
int HoursOverdue,
|
||||
string Url,
|
||||
bool AskOverdueReason,
|
||||
int RequestTypeId,
|
||||
string RequestTypeName,
|
||||
int RequestId,
|
||||
string Status,
|
||||
string? RequestUniqueCode);
|
||||
/// <remarks>
|
||||
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
|
||||
/// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
|
||||
/// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
|
||||
/// (Power Automate ссылается на неё, работая со списком согласования конкретного
|
||||
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
|
||||
///
|
||||
/// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
|
||||
/// список именно базового типа (IReadOnlyList<ReminderDto>) — без этих
|
||||
/// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
|
||||
/// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
|
||||
/// со значением "ApprovalOverdue"/"ProbationEnding".
|
||||
/// </remarks>
|
||||
[JsonPolymorphic]
|
||||
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
|
||||
[JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
|
||||
public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <summary>
|
||||
/// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(ReminderFeed, CancellationToken)"/>.
|
||||
///
|
||||
/// <see cref="None"/> («вид не указан») трактуется как <see cref="ApprovalOverdue"/>:
|
||||
/// до появления ProbationEnding эндпоинт отдавал только просроченные шаги, и
|
||||
/// вызов без явного выбора обязан работать как раньше.
|
||||
/// </summary>
|
||||
public enum ReminderFeed
|
||||
{
|
||||
None = 0,
|
||||
ApprovalOverdue = 1,
|
||||
ProbationEnding = 2,
|
||||
}
|
||||
@@ -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<RemindersOptions> options,
|
||||
ILogger<ReminderService> logger)
|
||||
{
|
||||
@@ -30,6 +33,32 @@ public sealed class ReminderService(
|
||||
private const string ActiveStatusFilter =
|
||||
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
||||
|
||||
/// <summary>
|
||||
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
|
||||
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
|
||||
/// </summary>
|
||||
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(CancellationToken cancellationToken) =>
|
||||
GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей
|
||||
/// стороны. Момент «сейчас» берётся здесь же, из <see cref="TimeProvider"/>, а не
|
||||
/// пробрасывается вызывающей стороной — во всех проде это <c>TimeProvider.System</c>
|
||||
/// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/
|
||||
/// GetProbationEndingAsync по-прежнему принимают <c>now</c> явным параметром: так
|
||||
/// их сценарии остаются детерминированными без своего TimeProvider на каждый тест.
|
||||
/// </summary>
|
||||
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
|
||||
ReminderFeed feed, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return feed switch
|
||||
{
|
||||
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
|
||||
_ => GetOverdueAsync(now, cancellationToken),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Result<IReadOnlyList<ReminderDto>>> 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<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||||
@@ -273,4 +311,190 @@ public sealed class ReminderService(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Часовой пояс, в котором считаются КАЛЕНДАРНЫЕ даты испытательного срока.
|
||||
///
|
||||
/// Нужен потому, что портал пишет дату приёма как местную полночь, переведённую
|
||||
/// в UTC: в списке она лежит как 19:00Z ПРЕДЫДУЩЕГО дня. Сравнивая по UTC-датам,
|
||||
/// сервис видел бы дату приёма на сутки раньше той, что видит кадровик в
|
||||
/// портале, и напоминание уходило бы на день раньше срока.
|
||||
///
|
||||
/// Константой, а не настройкой: смещение фиксировано для всей организации и не
|
||||
/// меняется от окружения (перехода на летнее время нет).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan LocalOffset = TimeSpan.FromHours(6);
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, int> ProbationMonthsByOption =
|
||||
new Dictionary<string, int>
|
||||
{
|
||||
["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);
|
||||
|
||||
// Испытательный срок — не больше 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<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 [];
|
||||
|
||||
// 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<ReminderDto>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var reminder = await TryBuildProbationReminderAsync(row, directory, now, cancellationToken);
|
||||
if (reminder is not null) reminders.Add(reminder);
|
||||
}
|
||||
|
||||
return reminders;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ни type, ни listId здесь больше не нужны: ссылка в этом виде напоминаний —
|
||||
/// константа из настроек (см. ProbationReminderUrl), а поля самой заявки
|
||||
/// (RequestTypeId и т.п.) в ProbationEndingReminderDto не входят.
|
||||
/// </summary>
|
||||
private async Task<ReminderDto?> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,35 @@ 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; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Куда ведёт ссылка в напоминании об окончании испытательного срока — на
|
||||
/// раздел «Подать заявку» портала, а НЕ на саму заявку на приём: от
|
||||
/// руководителя по этому напоминанию требуется завести НОВУЮ заявку (решение
|
||||
/// по итогам испытательного срока), а не открыть уже закрытую старую.
|
||||
///
|
||||
/// Поэтому ссылка константная, без подстановок: адрес конкретной формы в
|
||||
/// портал вообще не передать — параметр rptype без rpid/rpsource
|
||||
/// игнорируется, и маршрут сваливается в раздел «Подать заявку» (см.
|
||||
/// parseRoute в request-portal/shared/routing.ts). Отдельная настройка, а не
|
||||
/// хардкод, — чтобы адрес правился конфигурацией.
|
||||
/// </summary>
|
||||
public string ProbationReminderUrl { get; set; } =
|
||||
"https://tnsukz.sharepoint.com/sites/requests?rpnav=new";
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed record CreateTemplateRouteStepRequest(
|
||||
RouteStepStatus Status,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
IReadOnlyList<RouteStepConditionDto>? 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<RouteStepConditionDto>? Conditions,
|
||||
ConditionsLogic ConditionsLogic = ConditionsLogic.And,
|
||||
ConditionsLogic ConditionsLogic,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
|
||||
+35
@@ -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<
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// У PATCH-запроса шага валидатора раньше не было вовсе — здесь он ровно про одно:
|
||||
/// не дать протащить невалидные значения-заглушки в поля, которые запрос ЯВНО
|
||||
/// задаёт. null остаётся «не менять» и не проверяется (см. PatchTemplateRouteStepRequest).
|
||||
/// </summary>
|
||||
public sealed class PatchTemplateRouteStepRequestValidator : AbstractValidator<PatchTemplateRouteStepRequest>
|
||||
{
|
||||
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) =>
|
||||
|
||||
Reference in New Issue
Block a user