From 554f9b25286ed73fb9334cb0e9c6f6185282cab4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 27 Aug 2026 15:21:34 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/RemindersController.cs | 9 ++-- backend/Tnsu.ApprovalRoute.Api/Program.cs | 1 + .../Reminders/ReminderFeed.cs | 16 +++++++ .../Reminders/ReminderService.cs | 46 ++++++++++++++----- .../ReminderServiceTests.cs | 40 +++++++++++++++- 5 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs index 584abb8..4d61bfe 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/RemindersController.cs @@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Api.Controllers; /// -/// Просроченные шаги согласования и исполнения на текущий момент. +/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без +/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до +/// появления ProbationEnding. /// /// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает /// Power Automate по расписанию (токен приложения), но и из админки его удобно @@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas { [HttpGet] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] - public async Task List(CancellationToken ct) => - (await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult(); + public async Task List( + [FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) => + (await service.GetRemindersAsync(feed, ct)).ToActionResult(); } diff --git a/backend/Tnsu.ApprovalRoute.Api/Program.cs b/backend/Tnsu.ApprovalRoute.Api/Program.cs index 16109f4..48a3aee 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Program.cs @@ -52,6 +52,7 @@ builder.Services.AddCors(options => .AllowAnyHeader() .AllowAnyMethod()); }); +builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteApplication(); builder.Services.Configure(builder.Configuration.GetSection("Reminders")); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs new file mode 100644 index 0000000..1b3dd50 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderFeed.cs @@ -0,0 +1,16 @@ +namespace Tnsu.ApprovalRoute.Application.Reminders; + +/// +/// Какой вид напоминаний запросить у . +/// +/// GetRemindersAsync изначально (до появления ProbationEnding) отдавал только +/// просроченные шаги согласования — этот вид и остаётся значением по умолчанию +/// (0), чтобы старые потребители (Power Automate), не указывающие feed явно, +/// продолжали получать ровно то же, что и раньше. +/// +public enum ReminderFeed +{ + None = 0, + ApprovalOverdue = 1, + ProbationEnding = 2 +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs index 1becb20..ac1d089 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reminders/ReminderService.cs @@ -24,6 +24,7 @@ public sealed class ReminderService( ISharePointClient sharePoint, ListPartitionRegistry partitionRegistry, ITnsuPortalClient portalClient, + TimeProvider timeProvider, IOptions options, ILogger logger) { @@ -32,17 +33,30 @@ public sealed class ReminderService( private const string ActiveStatusFilter = $"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'"; - /// Оба вида напоминаний разом — то, что реально отдаёт контроллер. - public async Task>> GetRemindersAsync( - DateTimeOffset now, CancellationToken cancellationToken) + /// + /// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше + /// это был единственный вид напоминаний, и без явного выбора им и остаётся. + /// + public Task>> GetRemindersAsync(CancellationToken cancellationToken) => + GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken); + + /// + /// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей + /// стороны. Момент «сейчас» берётся здесь же, из , а не + /// пробрасывается вызывающей стороной — во всех проде это TimeProvider.System + /// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/ + /// GetProbationEndingAsync по-прежнему принимают now явным параметром: так + /// их сценарии остаются детерминированными без своего TimeProvider на каждый тест. + /// + public Task>> GetRemindersAsync( + ReminderFeed feed, CancellationToken cancellationToken) { - var overdue = await GetOverdueAsync(now, cancellationToken); - if (overdue.IsFailed) return overdue; - - var probation = await GetProbationEndingAsync(now, cancellationToken); - if (probation.IsFailed) return probation; - - return Result.Ok>(overdue.Value.Concat(probation.Value).ToList()); + var now = timeProvider.GetUtcNow(); + return feed switch + { + ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken), + _ => GetOverdueAsync(now, cancellationToken), + }; } public async Task>> GetOverdueAsync( @@ -191,9 +205,17 @@ public sealed class ReminderService( // ДО появления колонки (см. ApprovalRow) или если request-portal // почему-то не проставил её при активации. Без даты старта SLA не // от чего отсчитывать — пропускаем, а не подставляем сейчас. + // + // RequestTypeId — всегда, чтобы искать TicketId в правильном типе + // заявок. RequestUniqueCode — то, по чему заявку реально ищут в + // портале, добавляется, только если он уже есть (строка данных + // могла ещё не появиться или заявка старше появления колонки). + var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code + ? $"requestTypeId={type.Id}, code={code}" + : $"requestTypeId={type.Id}"; logger.LogWarning( - "Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена", - row.Id, row.TicketId); + "Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена", + row.Id, row.TicketId, identifier); continue; } diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs index a2415ec..69eacf9 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs @@ -33,6 +33,16 @@ public sealed class ReminderServiceTests : IClassFixture now; + } + + private static readonly TimeProvider Clock = new FixedTimeProvider(Now); + private readonly ServiceProvider _services; private readonly RequestTypeService _requestTypeService; @@ -124,6 +134,7 @@ public sealed class ReminderServiceTests : IClassFixture(), Mock.Of(), + Clock, Options.Create(new RemindersOptions()), NullLogger.Instance); } @@ -145,6 +156,32 @@ public sealed class ReminderServiceTests : IClassFixture().HoursOverdue.ShouldBe(24); + } + + [Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")] + public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue() + { + var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24); + var service = BuildService(listUrl, + [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]); + + var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None); + + result.IsSuccess.ShouldBeTrue(); + result.Value.ShouldBeEmpty(); + } + [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] public async Task WithinSla_ReturnsNothing() { @@ -317,6 +354,7 @@ public sealed class ReminderServiceTests : IClassFixture(), portalClient.Object, + Clock, Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }), NullLogger.Instance); } @@ -519,7 +557,7 @@ public sealed class ReminderServiceTests : IClassFixture.Instance);