основное сделано
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
@@ -11,6 +12,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<ReminderService>();
|
||||
services.AddScoped<RequestTypeService>();
|
||||
services.AddScoped<TemplateRouteService>();
|
||||
services.AddScoped<TemplateRouteStepService>();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||
/// именно он эти списки и создаёт.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalRow(
|
||||
int Id,
|
||||
int TicketId,
|
||||
int StepOrder,
|
||||
string Status,
|
||||
string Phase,
|
||||
DateTimeOffset? Date,
|
||||
string? ApproverEmail,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason)
|
||||
{
|
||||
public static ApprovalRow? TryParse(JsonElement item)
|
||||
{
|
||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||
|
||||
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||
? GetString(a, "EMail")
|
||||
: null;
|
||||
|
||||
return new ApprovalRow(
|
||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||
ticketId,
|
||||
TryGetInt(item, "StepOrder", out var order) ? order : 0,
|
||||
GetString(item, "ApprovalStatus") ?? string.Empty,
|
||||
// Пустая фаза — старые строки, заведённые до появления колонки;
|
||||
// трактуем как «Согласование» (так же поступает request-portal).
|
||||
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||
TryGetDate(item, "Date"),
|
||||
approver,
|
||||
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
private static bool TryGetInt(JsonElement e, string name, out int value)
|
||||
{
|
||||
value = 0;
|
||||
return e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out value);
|
||||
}
|
||||
|
||||
private static bool TryGetBool(JsonElement e, string name, out bool value)
|
||||
{
|
||||
value = false;
|
||||
if (!e.TryGetProperty(name, out var v)) return false;
|
||||
if (v.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
|
||||
value = v.GetBoolean();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? TryGetDate(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) &&
|
||||
v.ValueKind == JsonValueKind.String &&
|
||||
DateTimeOffset.TryParse(v.GetString(), out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static class ApprovalPhases
|
||||
{
|
||||
public const string Approval = "Согласование";
|
||||
public const string Execution = "Исполнение";
|
||||
}
|
||||
|
||||
internal static class ApprovalStatuses
|
||||
{
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string OnExecution = "На исполнении";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Managed property статуса строки маршрута для KQL — ApprovalStatusOWSCHCS.
|
||||
/// Имя нестандартное (суффикс OWSCHCS у Choice-поля, префикс группы колонок
|
||||
/// сайта у самого имени) — совпадает с MP_STATUS в
|
||||
/// request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts,
|
||||
/// это тот же список и та же колонка, имя проверено там же.
|
||||
/// </summary>
|
||||
internal static class SlaStatus
|
||||
{
|
||||
private const string ManagedProperty = "ApprovalStatusOWSCHCS";
|
||||
|
||||
public static readonly string OnApprovalProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnApproval}\"";
|
||||
public static readonly string OnExecutionProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnExecution}\"";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param>
|
||||
/// <param name="HoursOverdue">
|
||||
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
|
||||
/// Выходные не учитываются — считаются календарные часы.
|
||||
/// </param>
|
||||
/// <param name="Url">Ссылка на заявку, требующую решения.</param>
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
|
||||
/// наследованием (шаг перекрывает тип заявки).
|
||||
/// </param>
|
||||
public sealed record ReminderDto(
|
||||
string Email,
|
||||
int HoursOverdue,
|
||||
string Url,
|
||||
bool AskOverdueReason,
|
||||
int RequestTypeId,
|
||||
string RequestTypeName,
|
||||
int RequestId);
|
||||
@@ -0,0 +1,295 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||||
|
||||
public sealed class RemindersOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Шаблон ссылки на заявку. Подстановки: {siteUrl}, {pageUrl}, {requestId}, {nav}.
|
||||
///
|
||||
/// Портал — одностраничный и живёт в КОРНЕ сайта, отдельного пути к странице
|
||||
/// нет; всё состояние передаётся параметрами запроса (см. shared/routing.ts
|
||||
/// в request-portal). Пример живой ссылки:
|
||||
/// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11
|
||||
///
|
||||
/// {pageUrl} — это PageUrl типа заявки, то есть КЛЮЧ ФОРМЫ (it-1c-ticket), а
|
||||
/// не путь к странице: в портале он и подставляется в rptype.
|
||||
///
|
||||
/// Вынесено в конфигурацию, чтобы смена разметки ссылок в портале правилась
|
||||
/// настройкой, а не пересборкой сервиса.
|
||||
/// </summary>
|
||||
public string RequestUrlTemplate { get; set; } =
|
||||
"{siteUrl}?rpnav={nav}&rpmode=approve&rptype={pageUrl}&rpid={requestId}";
|
||||
}
|
||||
@@ -1,9 +1,42 @@
|
||||
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
|
||||
public sealed record RequestTypeDto(int Id, string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
|
||||
// SlaHours/AskOverdueReason добавлены в конец с значением по умолчанию null —
|
||||
// оба поля необязательны, и старые клиенты, не знающие о них, продолжают
|
||||
// работать без изменений. null здесь значит «не настроено», а не «ноль»:
|
||||
// см. RequestType.SlaHours.
|
||||
public sealed record RequestTypeDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string PageUrl,
|
||||
string? ListId,
|
||||
string? SiteUrl,
|
||||
string Section,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record CreateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
|
||||
public sealed record CreateRequestTypeRequest(
|
||||
string Name,
|
||||
string PageUrl,
|
||||
string? ListId,
|
||||
string? SiteUrl,
|
||||
string Section,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl, string? ListId, string? SiteUrl, string Section);
|
||||
public sealed record UpdateRequestTypeRequest(
|
||||
string Name,
|
||||
string PageUrl,
|
||||
string? ListId,
|
||||
string? SiteUrl,
|
||||
string Section,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl, string? ListId, string? SiteUrl, string? Section);
|
||||
public sealed record PatchRequestTypeRequest(
|
||||
string? Name,
|
||||
string? PageUrl,
|
||||
string? ListId,
|
||||
string? SiteUrl,
|
||||
string? Section,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
{
|
||||
var items = await db.RequestTypes.AsNoTracking()
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section))
|
||||
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl, x.ListId, x.SiteUrl, x.Section, x.SlaHours, x.AskOverdueReason))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||
@@ -39,7 +39,9 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
PageUrl = request.PageUrl,
|
||||
ListId = request.ListId,
|
||||
SiteUrl = request.SiteUrl,
|
||||
Section = request.Section
|
||||
Section = request.Section,
|
||||
SlaHours = request.SlaHours,
|
||||
AskOverdueReason = request.AskOverdueReason
|
||||
};
|
||||
db.RequestTypes.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -62,6 +64,8 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
entity.ListId = request.ListId;
|
||||
entity.SiteUrl = request.SiteUrl;
|
||||
entity.Section = request.Section;
|
||||
entity.SlaHours = request.SlaHours;
|
||||
entity.AskOverdueReason = request.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
@@ -85,6 +89,11 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
entity.ListId = listId;
|
||||
entity.SiteUrl = siteUrl;
|
||||
entity.Section = request.Section ?? entity.Section;
|
||||
// PATCH здесь традиционно трактует null как «не менять» (как и остальные
|
||||
// поля выше), поэтому сбросить SLA обратно в «наследовать» можно только
|
||||
// через PUT — там значение записывается как есть.
|
||||
entity.SlaHours = request.SlaHours ?? entity.SlaHours;
|
||||
entity.AskOverdueReason = request.AskOverdueReason ?? entity.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(ToDto(entity));
|
||||
@@ -102,5 +111,6 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section);
|
||||
private static RequestTypeDto ToDto(RequestType e) =>
|
||||
new(e.Id, e.Name, e.PageUrl, e.ListId, e.SiteUrl, e.Section, e.SlaHours, e.AskOverdueReason);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ public sealed class CreateRequestTypeRequestValidator : AbstractValidator<Create
|
||||
RuleFor(x => x)
|
||||
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.SlaHours is not null)
|
||||
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +31,12 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<Update
|
||||
RuleFor(x => x)
|
||||
.Must(x => RequestTypeFields.IsListReferenceValid(x.ListId, x.SiteUrl))
|
||||
.WithMessage("Id списка и url сайта SharePoint должны быть заполнены оба вместе или не заполнены вовсе.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.SlaHours is not null)
|
||||
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +50,12 @@ public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRe
|
||||
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
||||
When(x => x.Section is not null, () =>
|
||||
RuleFor(x => x.Section).NotEmpty().WithMessage("Раздел не может быть пустым."));
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.SlaHours is not null)
|
||||
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
// SlaHours/AskOverdueReason у шага перекрывают одноимённые поля типа заявки.
|
||||
// null — «не задано, наследовать», поэтому оба поля nullable и необязательны
|
||||
// (0 часов и false — это осознанный выбор, а не «пусто»). Добавлены в конец
|
||||
// записей, чтобы не ломать существующие позиционные вызовы.
|
||||
|
||||
public sealed record TemplateRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
@@ -15,7 +20,9 @@ public sealed record TemplateRouteStepDto(
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value,
|
||||
string? Warning = null);
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record CreateTemplateRouteStepRequest(
|
||||
int OrderNo,
|
||||
@@ -27,7 +34,9 @@ public sealed record CreateTemplateRouteStepRequest(
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value);
|
||||
string? Value,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record UpdateTemplateRouteStepRequest(
|
||||
int OrderNo,
|
||||
@@ -39,7 +48,9 @@ public sealed record UpdateTemplateRouteStepRequest(
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value);
|
||||
string? Value,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record PatchTemplateRouteStepRequest(
|
||||
int? OrderNo,
|
||||
@@ -51,4 +62,6 @@ public sealed record PatchTemplateRouteStepRequest(
|
||||
bool? AllowReassignToOtherApprovers,
|
||||
string? FieldName,
|
||||
ComparisonOperator? Operation,
|
||||
string? Value);
|
||||
string? Value,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
+11
-3
@@ -16,7 +16,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
.OrderBy(x => x.OrderNo)
|
||||
.Select(x => new TemplateRouteStepDto(
|
||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
|
||||
x.FieldName, x.Operation, x.Value))
|
||||
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
@@ -73,7 +73,9 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers,
|
||||
FieldName = r.FieldName,
|
||||
Operation = r.Operation,
|
||||
Value = r.Value
|
||||
Value = r.Value,
|
||||
SlaHours = r.SlaHours,
|
||||
AskOverdueReason = r.AskOverdueReason
|
||||
};
|
||||
db.TemplateRouteSteps.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
@@ -125,6 +127,8 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
entity.FieldName = r.FieldName;
|
||||
entity.Operation = r.Operation;
|
||||
entity.Value = r.Value;
|
||||
entity.SlaHours = r.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||
@@ -190,6 +194,10 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
entity.FieldName = fieldName;
|
||||
entity.Operation = operation;
|
||||
entity.Value = value;
|
||||
// Как и остальные поля в PATCH, null значит «не менять». Вернуть шаг к
|
||||
// наследованию SLA от типа заявки можно через PUT.
|
||||
entity.SlaHours = r.SlaHours ?? entity.SlaHours;
|
||||
entity.AskOverdueReason = r.AskOverdueReason ?? entity.AskOverdueReason;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||
@@ -281,5 +289,5 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||
|
||||
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers,
|
||||
e.FieldName, e.Operation, e.Value);
|
||||
e.FieldName, e.Operation, e.Value, null, e.SlaHours, e.AskOverdueReason);
|
||||
}
|
||||
|
||||
+12
@@ -14,6 +14,12 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.SlaHours is not null)
|
||||
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +34,12 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
|
||||
RuleFor(x => x)
|
||||
.Must(x => TemplateRouteStepFields.IsConditionValid(x.FieldName, x.Operation, x.Value))
|
||||
.WithMessage("Поля условия (field_name, operation, value) должны быть заполнены все вместе или не заполнены вовсе.");
|
||||
// Отрицательный SLA сделал бы шаг просроченным уже в момент активации;
|
||||
// ноль допустим — это осознанное «напоминать сразу».
|
||||
RuleFor(x => x.SlaHours)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.SlaHours is not null)
|
||||
.WithMessage("SLA в часах не может быть отрицательным.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,26 @@ using Tnsu.ApprovalRoute.Domain.Enums;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||
|
||||
/// <param name="SlaHours">
|
||||
/// ДЕЙСТВУЮЩИЙ SLA шага в часах — наследование уже применено: значение шага, а
|
||||
/// если на шаге не задано, то значение типа заявки. null означает, что SLA не
|
||||
/// настроен нигде, то есть напоминаний по этому шагу не будет.
|
||||
/// Резолв отдаёт именно итоговое значение, чтобы вызывающая сторона
|
||||
/// (request-portal) просто записала его в строку списка согласования и не
|
||||
/// повторяла у себя правило наследования.
|
||||
/// </param>
|
||||
/// <param name="AskOverdueReason">
|
||||
/// Спрашивать ли причину просрочки — так же с уже применённым наследованием.
|
||||
/// </param>
|
||||
public sealed record ResolvedRouteStepDto(
|
||||
int Id,
|
||||
int OrderNo,
|
||||
string? ExecutorEmail,
|
||||
string? Position,
|
||||
bool AllowReassignToOtherApprovers,
|
||||
string? Warning = null);
|
||||
string? Warning = null,
|
||||
int? SlaHours = null,
|
||||
bool? AskOverdueReason = null);
|
||||
|
||||
public sealed record ResolveTemplateRouteResultDto(
|
||||
int RouteId,
|
||||
|
||||
@@ -129,8 +129,11 @@ public sealed class TemplateRouteService(
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
|
||||
|
||||
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken);
|
||||
if (!requestTypeExists)
|
||||
// Сам тип заявки, а не только факт его существования: из него берутся
|
||||
// значения SLA по умолчанию для шагов (см. ResolveStepAsync).
|
||||
var requestType = await db.RequestTypes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == request.RequestTypeId, cancellationToken);
|
||||
if (requestType is null)
|
||||
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||
|
||||
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||
@@ -168,7 +171,8 @@ public sealed class TemplateRouteService(
|
||||
|
||||
var steps = new List<ResolvedRouteStepDto>();
|
||||
foreach (var step in matchingSteps)
|
||||
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, request.DepartmentId, cancellationToken));
|
||||
steps.Add(await ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken));
|
||||
|
||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||
|
||||
@@ -212,7 +216,8 @@ public sealed class TemplateRouteService(
|
||||
|
||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, CancellationToken cancellationToken)
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? resolvedEmail;
|
||||
string? warning = null;
|
||||
@@ -277,7 +282,19 @@ public sealed class TemplateRouteService(
|
||||
resolvedPosition = resolvedEmployee?.Position;
|
||||
}
|
||||
|
||||
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
|
||||
// Наследование SLA применяется ЗДЕСЬ, а не у вызывающей стороны: `??`
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
warning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user