Расширение reminders #19

Merged
gorbenko.pavel merged 8 commits from remimders-extension into main 2026-08-27 11:43:45 +00:00
5 changed files with 96 additions and 16 deletions
Showing only changes of commit 554f9b2528 - Show all commits
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.Api.Controllers;
/// <summary>
/// Просроченные шаги согласования и исполнения на текущий момент.
/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
/// появления ProbationEnding.
///
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
{
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(CancellationToken ct) =>
(await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
public async Task<IActionResult> List(
[FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
(await service.GetRemindersAsync(feed, ct)).ToActionResult();
}
@@ -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<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
@@ -0,0 +1,16 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Какой вид напоминаний запросить у <see cref="ReminderService.GetRemindersAsync(DateTimeOffset, ReminderFeed, CancellationToken)"/>.
///
/// GetRemindersAsync изначально (до появления ProbationEnding) отдавал только
/// просроченные шаги согласования — этот вид и остаётся значением по умолчанию
/// (0), чтобы старые потребители (Power Automate), не указывающие feed явно,
/// продолжали получать ровно то же, что и раньше.
/// </summary>
public enum ReminderFeed
{
None = 0,
ApprovalOverdue = 1,
ProbationEnding = 2
}
@@ -24,6 +24,7 @@ public sealed class ReminderService(
ISharePointClient sharePoint,
ListPartitionRegistry partitionRegistry,
ITnsuPortalClient portalClient,
TimeProvider timeProvider,
IOptions<RemindersOptions> options,
ILogger<ReminderService> logger)
{
@@ -32,17 +33,30 @@ public sealed class ReminderService(
private const string ActiveStatusFilter =
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
/// <summary>Оба вида напоминаний разом — то, что реально отдаёт контроллер.</summary>
public async Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
DateTimeOffset now, CancellationToken cancellationToken)
/// <summary>
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
/// </summary>
public Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(CancellationToken cancellationToken) =>
GetRemindersAsync(ReminderFeed.ApprovalOverdue, cancellationToken);
/// <summary>
/// То, что реально отдаёт контроллер — один вид напоминаний по выбору вызывающей
/// стороны. Момент «сейчас» берётся здесь же, из <see cref="TimeProvider"/>, а не
/// пробрасывается вызывающей стороной — во всех проде это <c>TimeProvider.System</c>
/// (см. Program.cs), в тестах подменяется, а нижестоящие GetOverdueAsync/
/// GetProbationEndingAsync по-прежнему принимают <c>now</c> явным параметром: так
/// их сценарии остаются детерминированными без своего TimeProvider на каждый тест.
/// </summary>
public Task<Result<IReadOnlyList<ReminderDto>>> 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<IReadOnlyList<ReminderDto>>(overdue.Value.Concat(probation.Value).ToList());
var now = timeProvider.GetUtcNow();
return feed switch
{
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
_ => GetOverdueAsync(now, cancellationToken),
};
}
public async Task<Result<IReadOnlyList<ReminderDto>>> 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;
}
@@ -33,6 +33,16 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero);
// GetOverdueAsync/GetProbationEndingAsync по-прежнему берут now параметром — их
// тесты ниже дают его напрямую. TimeProvider нужен только там, где ReminderService
// сам решает, «сейчас» сколько — то есть в GetRemindersAsync (см. её doc-комментарий).
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => 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<PostgresDatabaseFixture
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
Mock.Of<ITnsuPortalClient>(),
Clock,
Options.Create(new RemindersOptions()),
NullLogger<ReminderService>.Instance);
}
@@ -145,6 +156,32 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
reminder.RequestId.ShouldBe(1);
}
[Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")]
public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue()
{
var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetRemindersAsync(CancellationToken.None);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().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<PostgresDatabaseFixture
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
portalClient.Object,
Clock,
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }),
NullLogger<ReminderService>.Instance);
}
@@ -519,7 +557,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
var service = new ReminderService(
db, sharePoint.Object, partitionRegistry, portalClient.Object,
db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock,
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }),
NullLogger<ReminderService>.Instance);