правки
This commit is contained in:
@@ -7,7 +7,9 @@ using Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Просроченные шаги согласования и исполнения на текущий момент.
|
/// Один вид напоминаний по выбору вызывающей стороны (см. ReminderFeed) — без
|
||||||
|
/// параметра feed отдаёт просроченные шаги согласования и исполнения, как и до
|
||||||
|
/// появления ProbationEnding.
|
||||||
///
|
///
|
||||||
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
/// Политика — та же AnyAuth, что и у остальных контроллеров: эндпоинт вызывает
|
||||||
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
/// Power Automate по расписанию (токен приложения), но и из админки его удобно
|
||||||
@@ -21,6 +23,7 @@ public sealed class RemindersController(ReminderService service) : ControllerBas
|
|||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ReminderDto>>), StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> List(CancellationToken ct) =>
|
public async Task<IActionResult> List(
|
||||||
(await service.GetRemindersAsync(DateTimeOffset.UtcNow, ct)).ToActionResult();
|
[FromQuery] ReminderFeed feed = ReminderFeed.ApprovalOverdue, CancellationToken ct = default) =>
|
||||||
|
(await service.GetRemindersAsync(feed, ct)).ToActionResult();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ builder.Services.AddCors(options =>
|
|||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod());
|
.AllowAnyMethod());
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.Configure<RemindersOptions>(builder.Configuration.GetSection("Reminders"));
|
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,
|
ISharePointClient sharePoint,
|
||||||
ListPartitionRegistry partitionRegistry,
|
ListPartitionRegistry partitionRegistry,
|
||||||
ITnsuPortalClient portalClient,
|
ITnsuPortalClient portalClient,
|
||||||
|
TimeProvider timeProvider,
|
||||||
IOptions<RemindersOptions> options,
|
IOptions<RemindersOptions> options,
|
||||||
ILogger<ReminderService> logger)
|
ILogger<ReminderService> logger)
|
||||||
{
|
{
|
||||||
@@ -32,17 +33,30 @@ public sealed class ReminderService(
|
|||||||
private const string ActiveStatusFilter =
|
private const string ActiveStatusFilter =
|
||||||
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
||||||
|
|
||||||
/// <summary>Оба вида напоминаний разом — то, что реально отдаёт контроллер.</summary>
|
/// <summary>
|
||||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetRemindersAsync(
|
/// Оверлоад без feed — для обратной совместимости со старыми вызовами: раньше
|
||||||
DateTimeOffset now, CancellationToken cancellationToken)
|
/// это был единственный вид напоминаний, и без явного выбора им и остаётся.
|
||||||
|
/// </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);
|
var now = timeProvider.GetUtcNow();
|
||||||
if (overdue.IsFailed) return overdue;
|
return feed switch
|
||||||
|
{
|
||||||
var probation = await GetProbationEndingAsync(now, cancellationToken);
|
ReminderFeed.ProbationEnding => GetProbationEndingAsync(now, cancellationToken),
|
||||||
if (probation.IsFailed) return probation;
|
_ => GetOverdueAsync(now, cancellationToken),
|
||||||
|
};
|
||||||
return Result.Ok<IReadOnlyList<ReminderDto>>(overdue.Value.Concat(probation.Value).ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||||
@@ -191,9 +205,17 @@ public sealed class ReminderService(
|
|||||||
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||||||
// почему-то не проставил её при активации. Без даты старта SLA не
|
// почему-то не проставил её при активации. Без даты старта SLA не
|
||||||
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||||||
|
//
|
||||||
|
// RequestTypeId — всегда, чтобы искать TicketId в правильном типе
|
||||||
|
// заявок. RequestUniqueCode — то, по чему заявку реально ищут в
|
||||||
|
// портале, добавляется, только если он уже есть (строка данных
|
||||||
|
// могла ещё не появиться или заявка старше появления колонки).
|
||||||
|
var identifier = uniqueCodes.GetValueOrDefault(row.TicketId) is { Length: > 0 } code
|
||||||
|
? $"requestTypeId={type.Id}, code={code}"
|
||||||
|
: $"requestTypeId={type.Id}";
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
|
"Строка {Row} заявки {Ticket} ({Identifier}) активна, но дата активации (Date) не заполнена — пропущена",
|
||||||
row.Id, row.TicketId);
|
row.Id, row.TicketId, identifier);
|
||||||
continue;
|
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);
|
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 ServiceProvider _services;
|
||||||
private readonly RequestTypeService _requestTypeService;
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
@@ -124,6 +134,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
Mock.Of<ITnsuPortalClient>(),
|
Mock.Of<ITnsuPortalClient>(),
|
||||||
|
Clock,
|
||||||
Options.Create(new RemindersOptions()),
|
Options.Create(new RemindersOptions()),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
@@ -145,6 +156,32 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
reminder.RequestId.ShouldBe(1);
|
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 = "Срок ещё не вышел — напоминания нет")]
|
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
||||||
public async Task WithinSla_ReturnsNothing()
|
public async Task WithinSla_ReturnsNothing()
|
||||||
{
|
{
|
||||||
@@ -317,6 +354,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||||
portalClient.Object,
|
portalClient.Object,
|
||||||
|
Clock,
|
||||||
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }),
|
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
@@ -519,7 +557,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||||||
|
|
||||||
var service = new ReminderService(
|
var service = new ReminderService(
|
||||||
db, sharePoint.Object, partitionRegistry, portalClient.Object,
|
db, sharePoint.Object, partitionRegistry, portalClient.Object, Clock,
|
||||||
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }),
|
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }),
|
||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user