feat(автосогласование): флаг на шаге маршрута и фоновая задача по SLA
Шаг маршрута получает флаг «Автосогласование» (AutoApprove). Когда у активной строки такого шага истекает SLA, часовая задача Hangfire принимает за согласующего положительное решение: помечает строку, двигает очередь и пересчитывает статус заявки — тем же набором правил, что и портал, когда решение принимает человек (зеркало approverDecision.service.ts/decide). Флаг не nullable, в отличие от SLA рядом: наследовать его неоткуда — у типа заявки такого поля нет и быть не должно. - ApprovalRow и константы статусов переехали в общий Approvals: их теперь читают и напоминания, и автосогласование, разъезжаться разбору одного и того же списка нельзя; - SharePointClient научился MERGE'ить элементы (раньше умел только читать). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Application.Approvals;
|
||||
|
||||
/// <summary>
|
||||
/// Строка списка «… — согласование», разобранная из ответа SharePoint.
|
||||
/// Имена колонок — как в request-portal (FIELDS в approvalList.service.ts):
|
||||
/// именно он эти списки и создаёт.
|
||||
///
|
||||
/// Общая для напоминаний (ReminderService) и автосогласования
|
||||
/// (AutoApproveService) — оба читают ОДИН и тот же список теми же колонками, и
|
||||
/// расхождение в разборе (например, в TryGetInt, см. ниже) означало бы, что
|
||||
/// просроченным шаг считается в одном месте и не считается в другом.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalRow(
|
||||
int Id,
|
||||
int TicketId,
|
||||
string Status,
|
||||
string Phase,
|
||||
DateTimeOffset? Date,
|
||||
string? ApproverEmail,
|
||||
string? ApproverName,
|
||||
int? SlaHours,
|
||||
bool? AskOverdueReason,
|
||||
// StepOrder и AutoApprove нужны только автосогласованию: по первому оно
|
||||
// двигает очередь (активирует следующий шаг), по второму — вообще отбирает
|
||||
// строки. Напоминаниям они не нужны, но читаются одним разбором на оба
|
||||
// сценария — см. RowSelect у каждого из них.
|
||||
double Order = 0,
|
||||
bool AutoApprove = false)
|
||||
{
|
||||
public static ApprovalRow? TryParse(JsonElement item)
|
||||
{
|
||||
// TicketId — Lookup на заявку; без него строку не с чем связать.
|
||||
if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
|
||||
|
||||
var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
|
||||
? a
|
||||
: (JsonElement?)null;
|
||||
|
||||
return new ApprovalRow(
|
||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||
ticketId,
|
||||
GetString(item, "ApprovalStatus") ?? string.Empty,
|
||||
// Пустая фаза — старые строки, заведённые до появления колонки;
|
||||
// трактуем как «Согласование» (так же поступает request-portal).
|
||||
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
|
||||
TryGetDate(item, "Date"),
|
||||
approverObject is { } ao ? GetString(ao, "EMail") : null,
|
||||
// Title у персонального поля — не всегда заполнен (например, у
|
||||
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
|
||||
approverObject is { } an ? GetString(an, "Title") : null,
|
||||
TryGetInt(item, "SLAhours", out var sla) ? sla : null,
|
||||
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null,
|
||||
// StepOrder — Number, и он бывает ДРОБНЫМ: делегат вклинивается между
|
||||
// шагами долей (см. ApproverService.delegate в request-portal),
|
||||
// поэтому здесь double, а не int.
|
||||
TryGetDouble(item, "StepOrder", out var order) ? order : 0,
|
||||
TryGetBool(item, "AutoApprove", out var auto) && auto);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement e, string name) =>
|
||||
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Целое из JSON — с поправкой на то, КАК SharePoint отдаёт Number-колонки.
|
||||
///
|
||||
/// Одного TryGetInt32 здесь мало: у колонки типа Number значение приходит
|
||||
/// дробным («1.0», а не «1»), а TryGetInt32 на числе с десятичной точкой
|
||||
/// возвращает false — и поле молча становилось null. На фронтенде этой
|
||||
/// проблемы нет вовсе: в JavaScript 1.0 и 1 — одно и то же число, поэтому
|
||||
/// там те же данные читались верно, а на бэкенде терялись.
|
||||
///
|
||||
/// Строку тоже принимаем: с odata=verbose (и у части полей) SharePoint
|
||||
/// отдаёт числа строками.
|
||||
/// </summary>
|
||||
private static bool TryGetInt(JsonElement e, string name, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!e.TryGetProperty(name, out var v)) return false;
|
||||
|
||||
if (v.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (v.TryGetInt32(out value)) return true;
|
||||
if (!v.TryGetDouble(out var d) || d is < int.MinValue or > int.MaxValue) return false;
|
||||
value = (int)Math.Round(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (v.ValueKind != JsonValueKind.String) return false;
|
||||
if (!double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
return false;
|
||||
if (parsed is < int.MinValue or > int.MaxValue) return false;
|
||||
|
||||
value = (int)Math.Round(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Дробное число из JSON — с теми же оговорками, что и <see cref="TryGetInt"/>.</summary>
|
||||
private static bool TryGetDouble(JsonElement e, string name, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (!e.TryGetProperty(name, out var v)) return false;
|
||||
|
||||
if (v.ValueKind == JsonValueKind.Number) return v.TryGetDouble(out value);
|
||||
|
||||
return v.ValueKind == JsonValueKind.String &&
|
||||
double.TryParse(v.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, 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 JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
value = v.GetBoolean();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Да/Нет-колонка тоже может прийти строкой («true»/«false») — см. TryGetInt.
|
||||
return v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out value);
|
||||
}
|
||||
|
||||
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 = "Исполнение";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значения колонки ApprovalStatus — статус СТРОКИ маршрута (не заявки).
|
||||
/// Один в один с REQUEST_STATUS в request-portal
|
||||
/// (shared/approval/models/statuses.ts) — он эти значения и пишет.
|
||||
/// </summary>
|
||||
internal static class ApprovalStatuses
|
||||
{
|
||||
public const string Queued = "Ждёт очереди";
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string OnExecution = "На исполнении";
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Executed = "Выполнено";
|
||||
public const string Rejected = "Отклонено";
|
||||
public const string Returned = "Возвращена на доработку";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Значения колонки RequestStatus — статус ЗАЯВКИ целиком (список «… — данные»).
|
||||
/// Набор ДРУГОЙ, чем у строки маршрута: «Ждёт очереди»/«На исполнении» у заявки
|
||||
/// не бывает, а «Выполнение»/«Закрыта» не бывает у строки (см. тот же
|
||||
/// statuses.ts, где оба набора сведены).
|
||||
/// </summary>
|
||||
internal static class RequestStatuses
|
||||
{
|
||||
public const string OnApproval = "На согласовании";
|
||||
public const string Approved = "Согласовано";
|
||||
public const string Rejected = "Отклонено";
|
||||
public const string Returned = "Возвращена на доработку";
|
||||
public const string Execution = "Выполнение";
|
||||
public const string Closed = "Закрыта";
|
||||
}
|
||||
Reference in New Issue
Block a user