5d7503209a
Абстрактный базовый класс + два sealed-наследника вместо одного DTO с опциональными полями под обе категории — так специфичные для каждого вида напоминания поля не путаются с полями другого вида. Полиморфная сериализация System.Text.Json ([JsonPolymorphic]/[JsonDerivedType]) нужна, иначе список IReadOnlyList<ReminderDto> сериализовался бы только по общим полям базового класса.
447 lines
24 KiB
C#
447 lines
24 KiB
C#
using System.Text.Json;
|
||
using FluentResults;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Кто прямо сейчас просрочил свой шаг согласования или исполнения.
|
||
///
|
||
/// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный
|
||
/// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано —
|
||
/// рассылкой и защитой от повторов занимается вызывающая сторона (Power
|
||
/// Automate по расписанию).
|
||
/// </summary>
|
||
public sealed class ReminderService(
|
||
ApprovalRouteDbContext db,
|
||
ISharePointClient sharePoint,
|
||
ListPartitionRegistry partitionRegistry,
|
||
ITnsuPortalClient portalClient,
|
||
IOptions<RemindersOptions> options,
|
||
ILogger<ReminderService> logger)
|
||
{
|
||
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
||
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
||
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)
|
||
{
|
||
// Точка входа — типы заявок с настроенным SLA. Тип без SLA не участвует
|
||
// в напоминаниях вообще, даже если у его шагов SLA задан: включение
|
||
// напоминаний — решение уровня типа заявки.
|
||
var types = await db.RequestTypes.AsNoTracking()
|
||
.Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
// Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не
|
||
// потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких
|
||
// веток Task.WhenAll ниже валит его исключением ("A second operation
|
||
// was started on this context instance before a previous operation
|
||
// completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо
|
||
// проглатывает — часть напоминаний молча пропадала бы без всякой
|
||
// видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность
|
||
// здесь не стоит ощутимого времени.
|
||
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||
foreach (var type in types)
|
||
archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||
|
||
// Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не
|
||
// трогают) — обход распараллелен, иначе время растёт линейно с числом
|
||
// типов: каждый тип сам по себе делает несколько последовательных
|
||
// HTTP-запросов.
|
||
var results = await Task.WhenAll(
|
||
types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken)));
|
||
|
||
return Result.Ok<IReadOnlyList<ReminderDto>>(results.SelectMany(r => r).ToList());
|
||
}
|
||
|
||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeSafeAsync(
|
||
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
return await GetOverdueForTypeAsync(type, archived, now, cancellationToken);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
// Недоступный список одного типа не должен обнулять напоминания по
|
||
// всем остальным: Power Automate получит то, что удалось собрать.
|
||
logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в
|
||
/// архив, продолжает ждать чьего-то решения точно так же, как в текущем
|
||
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||
/// новых заявок завели следующий список.
|
||
///
|
||
/// archived — уже прочитан вызывающей стороной (GetOverdueAsync,
|
||
/// последовательно для всех типов разом) — здесь БД больше не трогаем,
|
||
/// только HTTP к SharePoint, поэтому вызов можно параллелить по типам.
|
||
/// </summary>
|
||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||
{
|
||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||
|
||
var partitions = archived
|
||
.Select(x => (x.ListId, x.Number))
|
||
.Append((current.ListId, current.Number));
|
||
|
||
var reminders = new List<ReminderDto>();
|
||
foreach (var (listId, number) in partitions)
|
||
{
|
||
try
|
||
{
|
||
reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken));
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
// Недоступная партиция (например, архивный список переименовали
|
||
// или удалили вручную) не должна обнулять напоминания по
|
||
// остальным партициям того же типа.
|
||
logger.LogError(
|
||
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
|
||
listId, number, type.Name);
|
||
}
|
||
}
|
||
|
||
return reminders;
|
||
}
|
||
|
||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
|
||
RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken)
|
||
{
|
||
var siteUrl = type.SiteUrl!;
|
||
|
||
// Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url
|
||
// список найдётся всегда.
|
||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||
if (string.IsNullOrWhiteSpace(dataListTitle))
|
||
{
|
||
logger.LogWarning(
|
||
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
|
||
number, type.Name, listId);
|
||
return [];
|
||
}
|
||
|
||
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
||
if (approvalListTitle is null)
|
||
{
|
||
logger.LogWarning(
|
||
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
|
||
dataListTitle);
|
||
return [];
|
||
}
|
||
|
||
// Активные строки — прямым $filter по полю ApprovalStatus.
|
||
//
|
||
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
||
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
||
// строки не нашёл: индекс мог не подхватить именно эту managed property
|
||
// для этого списка (она формируется автоматически, и после провижининга
|
||
// колонок SLA переиндексация могла ещё не пройти) — сама причина не
|
||
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
||
// он не отстаёт от реального состояния списка (не зависит от индексации
|
||
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
||
//
|
||
// Дата активации строки — прямо в ней самой (колонка Date): request-portal
|
||
// проставляет её в момент перехода строки в «На согласовании»/«На
|
||
// исполнении» (см. approvalList.service.ts/setActive) и никак иначе,
|
||
// поэтому больше не нужно ни читать весь маршрут заявки, ни дату
|
||
// создания заявки, ни выводить активацию из решений соседних шагов.
|
||
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
||
if (active.Count == 0) return [];
|
||
|
||
var uniqueCodes = await ReadUniqueCodesAsync(
|
||
siteUrl, type.ListId!, active.Select(r => r.TicketId).Distinct(), cancellationToken);
|
||
|
||
var reminders = new List<ReminderDto>();
|
||
foreach (var row in active)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
||
|
||
if (row.Date is not { } activatedAt)
|
||
{
|
||
// Строка активна, но Date пуста — так бывает у строк, заведённых
|
||
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||
// почему-то не проставил её при активации. Без даты старта SLA не
|
||
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||
logger.LogWarning(
|
||
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
|
||
row.Id, row.TicketId);
|
||
continue;
|
||
}
|
||
|
||
// SLA строки перекрывает типовой. У строк, созданных до появления
|
||
// колонки, значения нет — тогда работает SLA типа заявки.
|
||
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
||
var deadline = activatedAt.AddHours(slaHours);
|
||
if (deadline >= now) continue;
|
||
|
||
reminders.Add(new ApprovalOverdueReminderDto(
|
||
row.ApproverEmail!,
|
||
(int)Math.Floor((now - deadline).TotalHours),
|
||
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
|
||
BuildUrl(type, row, listId),
|
||
type.Id,
|
||
type.Name,
|
||
row.TicketId,
|
||
row.Status,
|
||
uniqueCodes.GetValueOrDefault(row.TicketId)));
|
||
}
|
||
|
||
return reminders;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
||
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
||
/// откроется панелью поверх раздела (rpmode=approve).
|
||
///
|
||
/// listId — server-relative url партиции (см. shared/routing.ts в
|
||
/// request-portal, параметр rplist): без него архивная заявка открылась бы
|
||
/// так, будто лежит в ТЕКУЩЕЙ партиции типа, и портал не нашёл бы её.
|
||
/// </summary>
|
||
private string BuildUrl(RequestType type, ApprovalRow row, string listId) =>
|
||
options.Value.RequestUrlTemplate
|
||
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
|
||
.Replace("{pageUrl}", type.PageUrl)
|
||
.Replace("{requestId}", row.TicketId.ToString())
|
||
.Replace("{listId}", Uri.EscapeDataString(listId))
|
||
.Replace("{nav}", row.Phase == ApprovalPhases.Execution ? "execution" : "approvals");
|
||
|
||
/// <summary>«Заявка на приём — данные» → «Заявка на приём — согласование».</summary>
|
||
private static string? ApprovalListTitleFor(string dataListTitle)
|
||
{
|
||
var index = dataListTitle.LastIndexOf('—');
|
||
if (index < 0) return null;
|
||
|
||
var suffix = dataListTitle[(index + 1)..].Trim();
|
||
return suffix.Equals("данные", StringComparison.OrdinalIgnoreCase)
|
||
? $"{dataListTitle[..index].TrimEnd()} — согласование"
|
||
: null;
|
||
}
|
||
|
||
private const string RowSelect =
|
||
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
|
||
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
||
|
||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
|
||
{
|
||
var items = await sharePoint.GetItemsByListTitleAsync(
|
||
siteUrl, listTitle, $"{RowSelect}&{filter}", cancellationToken);
|
||
|
||
return Parse(items);
|
||
}
|
||
|
||
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||
|
||
/// <summary>
|
||
/// RequestUniqueCode заявок из списка данных — только по заявкам, у которых
|
||
/// сейчас есть просроченный шаг (а не по всему списку: он может быть большим).
|
||
/// </summary>
|
||
private async Task<IReadOnlyDictionary<int, string?>> ReadUniqueCodesAsync(
|
||
string siteUrl, string dataListUrl, IEnumerable<int> ticketIds, CancellationToken cancellationToken)
|
||
{
|
||
var filter = string.Join(" or ", ticketIds.Select(id => $"Id eq {id}"));
|
||
if (filter.Length == 0) return new Dictionary<int, string?>();
|
||
|
||
var items = await sharePoint.GetItemsByListUrlAsync(
|
||
siteUrl, dataListUrl, $"$select=Id,RequestUniqueCode&$filter={filter}", cancellationToken);
|
||
|
||
var result = new Dictionary<int, string?>();
|
||
foreach (var item in items)
|
||
{
|
||
if (!item.TryGetProperty("Id", out var idProp) || !idProp.TryGetInt32(out var id)) continue;
|
||
|
||
result[id] = item.TryGetProperty("RequestUniqueCode", out var code) && code.ValueKind == JsonValueKind.String
|
||
? code.GetString()
|
||
: null;
|
||
}
|
||
|
||
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 ProbationEndingReminderDto(
|
||
ManagerEmail: managerEmail,
|
||
CandidateEmail: candidateEmail,
|
||
CandidateName: candidateName,
|
||
ProbationPeriod: row.ProbationPeriod,
|
||
HireDate: hireDate,
|
||
ProbationEndsAt: probationEndsAt,
|
||
DaysUntilProbationEnds: daysLeft,
|
||
Url: BuildProbationUrl(type, row, listId),
|
||
RequestTypeId: type.Id,
|
||
RequestTypeName: type.Name,
|
||
RequestId: row.Id,
|
||
Status: row.Status,
|
||
RequestUniqueCode: row.RequestUniqueCode);
|
||
}
|
||
|
||
/// <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");
|
||
}
|