Auto approve (#21)
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 3m39s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
This commit was merged in pull request #21.
This commit is contained in:
2026-09-14 09:01:56 +00:00
parent e2b6227015
commit 2b8fe77fac
24 changed files with 1443 additions and 29 deletions
@@ -1,117 +0,0 @@
using System.Globalization;
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,
string Status,
string Phase,
DateTimeOffset? Date,
string? ApproverEmail,
string? ApproverName,
int? SlaHours,
bool? AskOverdueReason)
{
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);
}
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;
}
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 = "Исполнение";
}
internal static class ApprovalStatuses
{
public const string OnApproval = "На согласовании";
public const string OnExecution = "На исполнении";
}
@@ -3,6 +3,7 @@ using FluentResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Approvals;
using Tnsu.ApprovalRoute.Application.ListPartitioning;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
@@ -163,7 +164,7 @@ public sealed class ReminderService(
return [];
}
var approvalListTitle = ApprovalListTitleFor(dataListTitle);
var approvalListTitle = ApprovalListNaming.ApprovalListTitleFor(dataListTitle);
if (approvalListTitle is null)
{
logger.LogWarning(
@@ -258,18 +259,6 @@ public sealed class ReminderService(
.Replace("{listId}", Uri.EscapeDataString(listId))
.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,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";