Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
T
2026-08-18 14:52:06 +05:00

226 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Кто прямо сейчас просрочил свой шаг согласования или исполнения.
///
/// Срез «на текущий момент», без памяти о том, что уже отправлено: повторный
/// вызов вернёт ту же заявку, пока по ней не примут решение. Так и задумано —
/// рассылкой и защитой от повторов занимается вызывающая сторона (Power
/// Automate по расписанию).
/// </summary>
public sealed class ReminderService(
ApprovalRouteDbContext db,
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
IOptions<RemindersOptions> options,
ILogger<ReminderService> logger)
{
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
private const string ActiveStatusFilter =
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
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);
var reminders = new List<ReminderDto>();
foreach (var type in types)
{
try
{
reminders.AddRange(await GetOverdueForTypeAsync(type, now, cancellationToken));
}
catch (Exception e)
{
// Недоступный список одного типа не должен обнулять напоминания по
// всем остальным: Power Automate получит то, что удалось собрать.
logger.LogError(e, "Напоминания по типу заявки «{Type}» не собраны", type.Name);
}
}
return Result.Ok<IReadOnlyList<ReminderDto>>(reminders);
}
/// <summary>
/// Обходит ВСЕ партиции типа заявки (архив + текущая) — заявка, уехавшая в
/// архив, продолжает ждать чьего-то решения точно так же, как в текущем
/// списке, и не должна пропадать из напоминаний только из-за того, что для
/// новых заявок завели следующий список.
///
/// EnsureCurrentRegisteredAsync — на случай, если этот тип заявок ни разу не
/// читали через ListPartitionService (сам реестр партиций мог ещё не знать
/// про текущий список): без неё серия была бы пустой и напоминания молча не
/// строились бы даже по текущему списку.
/// </summary>
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
{
await partitionRegistry.EnsureCurrentRegisteredAsync(type, cancellationToken);
var partitions = await db.RequestListPartitions.AsNoTracking()
.Where(x => x.RequestTypeId == type.Id)
.OrderBy(x => x.Number)
.ToListAsync(cancellationToken);
var reminders = new List<ReminderDto>();
foreach (var partition in partitions)
{
try
{
reminders.AddRange(await GetOverdueForPartitionAsync(type, partition, now, cancellationToken));
}
catch (Exception e)
{
// Недоступная партиция (например, архивный список переименовали
// или удалили вручную) не должна обнулять напоминания по
// остальным партициям того же типа.
logger.LogError(
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
partition.ListId, partition.Number, type.Name);
}
}
return reminders;
}
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
RequestType type, RequestListPartition partition, DateTimeOffset now, CancellationToken cancellationToken)
{
var siteUrl = type.SiteUrl!;
// Читаем Title ЖИВЬЁМ, а не из partition.Title (закешированного на момент
// создания партиции) — тот же принцип, что был здесь и раньше для
// единственного списка: заголовок могли переименовать вручную, и кеш
// тогда соврёт, а по url список найдётся всегда.
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, partition.ListId, cancellationToken);
if (string.IsNullOrWhiteSpace(dataListTitle))
{
logger.LogWarning(
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
partition.Number, type.Name, partition.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 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 ReminderDto(
row.ApproverEmail!,
(int)Math.Floor((now - deadline).TotalHours),
BuildUrl(type, row),
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
type.Id,
type.Name,
row.TicketId));
}
return reminders;
}
/// <summary>
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
/// откроется панелью поверх раздела (rpmode=approve).
/// </summary>
private string BuildUrl(RequestType type, ApprovalRow row) =>
options.Value.RequestUrlTemplate
.Replace("{siteUrl}", type.SiteUrl!.TrimEnd('/'))
.Replace("{pageUrl}", type.PageUrl)
.Replace("{requestId}", row.TicketId.ToString())
.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();
}