промежуточный коммит
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user