Расширение reminders #19
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+39
-1
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user