Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs
T
gorbenko.pavel 1ee1b71e3e
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 7m18s
Редактирование заявок у конкретного пользователя (#14)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14
2026-08-19 12:58:07 +00:00

272 lines
15 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;
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);
// Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним 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 ReminderDto(
row.ApproverEmail!,
(int)Math.Floor((now - deadline).TotalHours),
BuildUrl(type, row),
row.AskOverdueReason ?? type.AskOverdueReason ?? false,
type.Id,
type.Name,
row.TicketId,
row.Status,
uniqueCodes.GetValueOrDefault(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();
/// <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;
}
}