296 lines
16 KiB
C#
296 lines
16 KiB
C#
using System.Text.Json;
|
|
using FluentResults;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
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,
|
|
IOptions<RemindersOptions> options,
|
|
ILogger<ReminderService> logger)
|
|
{
|
|
// Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе
|
|
// на большом списке получится неподъёмной длины URL.
|
|
private const int IdChunkSize = 50;
|
|
|
|
// Верхняя граница на число одновременно активных шагов по типу заявки —
|
|
// запас с большим избытком (это не общий объём заявок, а только те, что
|
|
// прямо сейчас на согласовании/исполнении).
|
|
private const int SearchRowLimit = 5000;
|
|
|
|
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);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
|
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
|
{
|
|
var siteUrl = type.SiteUrl!;
|
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, type.ListId!, cancellationToken);
|
|
if (string.IsNullOrWhiteSpace(dataListTitle))
|
|
{
|
|
logger.LogWarning("Список данных типа «{Type}» не найден по url {Url}", type.Name, type.ListId);
|
|
return [];
|
|
}
|
|
|
|
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
|
|
if (approvalListTitle is null)
|
|
{
|
|
logger.LogWarning(
|
|
"Список «{List}» назван не по конвенции «… — данные», список согласования для него не найти",
|
|
dataListTitle);
|
|
return [];
|
|
}
|
|
|
|
// Шаг 1. Активные строки — через Search, а не через $filter: дело не в
|
|
// кириллице конкретно, а в том, что $filter по Choice-колонке в
|
|
// SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо
|
|
// ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки
|
|
// этого же списка уже проверена и используется в request-portal
|
|
// (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут.
|
|
//
|
|
// Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ
|
|
// нерешённые строки списка (queued + active), чтобы отобрать активные
|
|
// на клиенте — при большом архиве заявок это росло бы неограниченно.
|
|
// Search сужает набор на стороне SharePoint до тех, что реально нужны.
|
|
var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken);
|
|
if (string.IsNullOrWhiteSpace(relativeUrl))
|
|
{
|
|
logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle);
|
|
return [];
|
|
}
|
|
|
|
var pathClause = $"Path:\"{siteUrl.TrimEnd('/')}{relativeUrl}*\"";
|
|
var statusClause = $"({SlaStatus.OnApprovalProperty} OR {SlaStatus.OnExecutionProperty})";
|
|
var activeIds = await sharePoint.SearchListItemIdsAsync(
|
|
siteUrl, $"{pathClause} AND {statusClause}", SearchRowLimit, cancellationToken);
|
|
if (activeIds.Count == 0) return [];
|
|
|
|
// Search отдаёт только Id — реальные поля (email, SlaHours, TicketId и
|
|
// т.д.) дочитываем REST'ом, как и везде в этом сервисе (см. ReadCreatedAsync).
|
|
// Заодно и статус здесь берётся из свежих данных, а не из индекса, который
|
|
// мог успеть отстать (та же оговорка, что в request-portal).
|
|
var active = (await ReadRowsByIdsAsync(siteUrl, approvalListTitle, activeIds, cancellationToken))
|
|
.Where(r => r.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution)
|
|
.ToList();
|
|
if (active.Count == 0) return [];
|
|
|
|
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
|
|
// дата активации шага выводится из дат решений предыдущего шага.
|
|
var ticketIds = active.Select(r => r.TicketId).Distinct().ToList();
|
|
var allRows = await ReadRowsByTicketsAsync(siteUrl, approvalListTitle, ticketIds, cancellationToken);
|
|
var rowsByTicket = allRows.GroupBy(r => r.TicketId).ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
var createdByTicket = await ReadCreatedAsync(siteUrl, type.ListId!, ticketIds, cancellationToken);
|
|
|
|
var reminders = new List<ReminderDto>();
|
|
foreach (var row in active)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
|
if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue;
|
|
|
|
createdByTicket.TryGetValue(row.TicketId, out var created);
|
|
var activatedAt = ActivatedAt(row, ticketRows, created);
|
|
if (activatedAt is null)
|
|
{
|
|
logger.LogWarning(
|
|
"Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена",
|
|
row.Id, row.TicketId);
|
|
continue;
|
|
}
|
|
|
|
// SLA строки перекрывает типовой. У строк, созданных до появления
|
|
// колонки, значения нет — тогда работает SLA типа заявки.
|
|
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
|
var deadline = activatedAt.Value.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>
|
|
/// Момент, с которого шаг считается активным, — начало отсчёта SLA.
|
|
///
|
|
/// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
|
|
/// точечное обновление статуса, дату оно не пишет), поэтому момент выводится
|
|
/// из соседних строк:
|
|
/// • первый шаг согласования — дата создания заявки;
|
|
/// • первый шаг исполнения — последнее решение по фазе согласования
|
|
/// (фаза исполнения активируется только когда согласование пройдено целиком);
|
|
/// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы.
|
|
///
|
|
/// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить
|
|
/// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг
|
|
/// активируется только после решения последнего из них.
|
|
///
|
|
/// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не
|
|
/// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по
|
|
/// условию появления).
|
|
/// </summary>
|
|
private static DateTimeOffset? ActivatedAt(
|
|
ApprovalRow row, IReadOnlyList<ApprovalRow> ticketRows, DateTimeOffset? requestCreated)
|
|
{
|
|
var samePhase = ticketRows.Where(r => r.Phase == row.Phase).ToList();
|
|
var isFirstInPhase = samePhase.All(r => r.StepOrder >= row.StepOrder);
|
|
|
|
if (isFirstInPhase)
|
|
{
|
|
return row.Phase == ApprovalPhases.Execution
|
|
? LastDecisionOf(ticketRows.Where(r => r.Phase == ApprovalPhases.Approval))
|
|
: requestCreated;
|
|
}
|
|
|
|
var previousOrder = samePhase
|
|
.Where(r => r.StepOrder < row.StepOrder)
|
|
.Max(r => r.StepOrder);
|
|
|
|
return LastDecisionOf(samePhase.Where(r => r.StepOrder == previousOrder));
|
|
}
|
|
|
|
private static DateTimeOffset? LastDecisionOf(IEnumerable<ApprovalRow> rows)
|
|
{
|
|
var dates = rows.Select(r => r.Date).Where(d => d is not null).Select(d => d!.Value).ToList();
|
|
return dates.Count == 0 ? null : dates.Max();
|
|
}
|
|
|
|
/// <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,StepOrder,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);
|
|
}
|
|
|
|
/// <summary>Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync).</summary>
|
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByIdsAsync(
|
|
string siteUrl, string listTitle, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
|
{
|
|
var rows = new List<ApprovalRow>();
|
|
foreach (var chunk in ids.Chunk(IdChunkSize))
|
|
{
|
|
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
|
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
|
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
|
{
|
|
var rows = new List<ApprovalRow>();
|
|
foreach (var chunk in ticketIds.Chunk(IdChunkSize))
|
|
{
|
|
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"TicketId eq {id}"));
|
|
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/// <summary>Даты создания заявок — начало отсчёта для первого шага согласования.</summary>
|
|
private async Task<Dictionary<int, DateTimeOffset>> ReadCreatedAsync(
|
|
string siteUrl, string listUrl, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
|
{
|
|
var created = new Dictionary<int, DateTimeOffset>();
|
|
|
|
foreach (var chunk in ids.Chunk(IdChunkSize))
|
|
{
|
|
var query = "$select=Id,Created&$top=5000&$filter=" +
|
|
string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
|
|
|
var items = await sharePoint.GetItemsByListUrlAsync(siteUrl, listUrl, query, cancellationToken);
|
|
foreach (var item in items)
|
|
{
|
|
if (!item.TryGetProperty("Id", out var idValue) || !idValue.TryGetInt32(out var id)) continue;
|
|
if (!item.TryGetProperty("Created", out var createdValue)) continue;
|
|
if (createdValue.ValueKind != JsonValueKind.String) continue;
|
|
if (DateTimeOffset.TryParse(createdValue.GetString(), out var parsed)) created[id] = parsed;
|
|
}
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
|
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
|
}
|