From a05446447e50c261a2d135ede8366cc8a068bcc7 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 10:23:30 +0500 Subject: [PATCH] =?UTF-8?q?reminder=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reminders/ApprovalRow.cs | 2 - .../Reminders/ReminderService.cs | 117 ++-------------- .../ReminderServiceTests.cs | 132 ++++++------------ 3 files changed, 60 insertions(+), 191 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs index dacb884..cd5d224 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ApprovalRow.cs @@ -11,7 +11,6 @@ namespace Tnsu.ApprovalRoute.Application.Reminders; internal sealed record ApprovalRow( int Id, int TicketId, - int StepOrder, string Status, string Phase, DateTimeOffset? Date, @@ -31,7 +30,6 @@ internal sealed record ApprovalRow( 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). diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 9c68e35..f29ff7c 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -23,10 +23,6 @@ public sealed class ReminderService( IOptions options, ILogger logger) { - // Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе - // на большом списке получится неподъёмной длины URL. - private const int IdChunkSize = 50; - // $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск, // поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов). private const string ActiveStatusFilter = @@ -81,7 +77,7 @@ public sealed class ReminderService( return []; } - // Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus. + // Активные строки — прямым $filter по полю ApprovalStatus. // // Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) — // тот же приём, что в request-portal (см. MP_STATUS). На практике он @@ -91,29 +87,28 @@ public sealed class ReminderService( // подтверждена, а вот результат — нет. Прямой $filter по полю сработал: // он не отстаёт от реального состояния списка (не зависит от индексации // вовсе), и подтверждённой на практике проблемы с ним на этом списке нет. + // + // Дата активации строки — прямо в ней самой (колонка Date): request-portal + // проставляет её в момент перехода строки в «На согласовании»/«На + // исполнении» (см. approvalList.service.ts/setActive) и никак иначе, + // поэтому больше не нужно ни читать весь маршрут заявки, ни дату + // создания заявки, ни выводить активацию из решений соседних шагов. var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken); 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(); 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) + if (row.Date is not { } activatedAt) { + // Строка активна, но Date пуста — так бывает у строк, заведённых + // ДО появления колонки (см. ApprovalRow) или если request-portal + // почему-то не проставил её при активации. Без даты старта SLA не + // от чего отсчитывать — пропускаем, а не подставляем сейчас. logger.LogWarning( - "Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена", + "Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена", row.Id, row.TicketId); continue; } @@ -121,7 +116,7 @@ public sealed class ReminderService( // SLA строки перекрывает типовой. У строк, созданных до появления // колонки, значения нет — тогда работает SLA типа заявки. var slaHours = row.SlaHours ?? type.SlaHours!.Value; - var deadline = activatedAt.Value.AddHours(slaHours); + var deadline = activatedAt.AddHours(slaHours); if (deadline >= now) continue; reminders.Add(new ReminderDto( @@ -137,51 +132,6 @@ public sealed class ReminderService( return reminders; } - /// - /// Момент, с которого шаг считается активным, — начало отсчёта SLA. - /// - /// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это - /// точечное обновление статуса, дату оно не пишет), поэтому момент выводится - /// из соседних строк: - /// • первый шаг согласования — дата создания заявки; - /// • первый шаг исполнения — последнее решение по фазе согласования - /// (фаза исполнения активируется только когда согласование пройдено целиком); - /// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы. - /// - /// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить - /// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг - /// активируется только после решения последнего из них. - /// - /// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не - /// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по - /// условию появления). - /// - private static DateTimeOffset? ActivatedAt( - ApprovalRow row, IReadOnlyList 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 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(); - } - /// /// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг: /// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно @@ -207,7 +157,7 @@ public sealed class ReminderService( } private const string RowSelect = - "$select=Id,ApprovalStatus,StepOrder,Phase,Date,SLAhours,AskOverdueReason,TicketId," + + "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," + "TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000"; private async Task> ReadRowsAsync( @@ -219,43 +169,6 @@ public sealed class ReminderService( return Parse(items); } - private async Task> ReadRowsByTicketsAsync( - string siteUrl, string listTitle, IReadOnlyList ticketIds, CancellationToken cancellationToken) - { - var rows = new List(); - 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; - } - - /// Даты создания заявок — начало отсчёта для первого шага согласования. - private async Task> ReadCreatedAsync( - string siteUrl, string listUrl, IReadOnlyList ids, CancellationToken cancellationToken) - { - var created = new Dictionary(); - - 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 Parse(IEnumerable items) => items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index 3467c06..9d60089 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -13,9 +13,11 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; /// -/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA: -/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент -/// выводится из соседних строк — это самая хрупкая часть эндпоинта. +/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки +/// (колонка Date — request-portal проставляет её в момент, когда строка +/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше +/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты +/// и проверяют вместо прежнего вывода даты из соседних строк. /// /// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его /// возвращает REST, — так тесты заодно покрывают и разбор ответа. @@ -53,13 +55,12 @@ public sealed class ReminderServiceTests : IClassFixture JsonSerializer.SerializeToElement(value); private static object Row( - int id, int ticketId, int stepOrder, string status, string phase, - string? date = null, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) => + int id, int ticketId, string status, string phase, + string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) => new { Id = id, TicketId = ticketId, - StepOrder = stepOrder, ApprovalStatus = status, Phase = phase, Date = date, @@ -68,8 +69,13 @@ public sealed class ReminderServiceTests : IClassFixture allRows, DateTimeOffset requestCreated) + // Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) — + // мок отвечает так же: по значению статуса, а не по признаку решённости. + // Никаких запросов ни по TicketId, ни к списку данных за Created сервис + // больше не делает — если он вдруг снова начнёт, немонастроенный мок + // (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой, + // а не тихо проигнорирует лишний запрос. + private ReminderService BuildService(string listUrl, IReadOnlyList rows) { var sharePoint = new Mock(); @@ -77,9 +83,7 @@ public sealed class ReminderServiceTests : IClassFixture x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .ReturnsAsync(DataListTitle); - // Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок - // отвечает так же: по значению статуса, а не по признаку решённости. - var activeRows = allRows + var activeRows = rows .Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is "На согласовании" or "На исполнении") .ToList(); @@ -88,15 +92,6 @@ public sealed class ReminderServiceTests : IClassFixture(q => q.Contains("ApprovalStatus eq")), It.IsAny())) .ReturnsAsync(activeRows.Select(Json).ToList()); - sharePoint - .Setup(x => x.GetItemsByListTitleAsync( - SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("TicketId eq")), It.IsAny())) - .ReturnsAsync(allRows.Select(Json).ToList()); - - sharePoint - .Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny(), It.IsAny())) - .ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]); - return new ReminderService( _services.GetRequiredService(), sharePoint.Object, @@ -104,14 +99,13 @@ public sealed class ReminderServiceTests : IClassFixture.Instance); } - [Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")] - public async Task FirstApprovalStep_CountsFromRequestCreated() + [Fact(DisplayName = "SLA отсчитывается от Date активной строки")] + public async Task ActiveRow_CountsFromOwnDate() { - var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24); - // Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа. + var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24); + // Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа. var service = BuildService(listUrl, - [Row(10, 1, 1, "На согласовании", "Согласование")], - requestCreated: Now.AddHours(-48)); + [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]); var result = await service.GetOverdueAsync(Now, CancellationToken.None); @@ -127,70 +121,43 @@ public sealed class ReminderServiceTests : IClassFixture