Обходной лист #12
@@ -11,7 +11,6 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
|
|||||||
internal sealed record ApprovalRow(
|
internal sealed record ApprovalRow(
|
||||||
int Id,
|
int Id,
|
||||||
int TicketId,
|
int TicketId,
|
||||||
int StepOrder,
|
|
||||||
string Status,
|
string Status,
|
||||||
string Phase,
|
string Phase,
|
||||||
DateTimeOffset? Date,
|
DateTimeOffset? Date,
|
||||||
@@ -31,7 +30,6 @@ internal sealed record ApprovalRow(
|
|||||||
return new ApprovalRow(
|
return new ApprovalRow(
|
||||||
TryGetInt(item, "Id", out var id) ? id : 0,
|
TryGetInt(item, "Id", out var id) ? id : 0,
|
||||||
ticketId,
|
ticketId,
|
||||||
TryGetInt(item, "StepOrder", out var order) ? order : 0,
|
|
||||||
GetString(item, "ApprovalStatus") ?? string.Empty,
|
GetString(item, "ApprovalStatus") ?? string.Empty,
|
||||||
// Пустая фаза — старые строки, заведённые до появления колонки;
|
// Пустая фаза — старые строки, заведённые до появления колонки;
|
||||||
// трактуем как «Согласование» (так же поступает request-portal).
|
// трактуем как «Согласование» (так же поступает request-portal).
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ public sealed class ReminderService(
|
|||||||
IOptions<RemindersOptions> options,
|
IOptions<RemindersOptions> options,
|
||||||
ILogger<ReminderService> logger)
|
ILogger<ReminderService> logger)
|
||||||
{
|
{
|
||||||
// Ограничение на длину OData-фильтра: Id заявок подставляются пачками, иначе
|
|
||||||
// на большом списке получится неподъёмной длины URL.
|
|
||||||
private const int IdChunkSize = 50;
|
|
||||||
|
|
||||||
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
||||||
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
||||||
private const string ActiveStatusFilter =
|
private const string ActiveStatusFilter =
|
||||||
@@ -81,7 +77,7 @@ public sealed class ReminderService(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus.
|
// Активные строки — прямым $filter по полю ApprovalStatus.
|
||||||
//
|
//
|
||||||
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
||||||
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
||||||
@@ -91,29 +87,28 @@ public sealed class ReminderService(
|
|||||||
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
||||||
// он не отстаёт от реального состояния списка (не зависит от индексации
|
// он не отстаёт от реального состояния списка (не зависит от индексации
|
||||||
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
||||||
|
//
|
||||||
|
// Дата активации строки — прямо в ней самой (колонка Date): request-portal
|
||||||
|
// проставляет её в момент перехода строки в «На согласовании»/«На
|
||||||
|
// исполнении» (см. approvalList.service.ts/setActive) и никак иначе,
|
||||||
|
// поэтому больше не нужно ни читать весь маршрут заявки, ни дату
|
||||||
|
// создания заявки, ни выводить активацию из решений соседних шагов.
|
||||||
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
||||||
if (active.Count == 0) return [];
|
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<ReminderDto>();
|
var reminders = new List<ReminderDto>();
|
||||||
foreach (var row in active)
|
foreach (var row in active)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
if (string.IsNullOrWhiteSpace(row.ApproverEmail)) continue;
|
||||||
if (!rowsByTicket.TryGetValue(row.TicketId, out var ticketRows)) continue;
|
|
||||||
|
|
||||||
createdByTicket.TryGetValue(row.TicketId, out var created);
|
if (row.Date is not { } activatedAt)
|
||||||
var activatedAt = ActivatedAt(row, ticketRows, created);
|
|
||||||
if (activatedAt is null)
|
|
||||||
{
|
{
|
||||||
|
// Строка активна, но Date пуста — так бывает у строк, заведённых
|
||||||
|
// ДО появления колонки (см. ApprovalRow) или если request-portal
|
||||||
|
// почему-то не проставил её при активации. Без даты старта SLA не
|
||||||
|
// от чего отсчитывать — пропускаем, а не подставляем сейчас.
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Не удалось определить дату активации строки {Row} заявки {Ticket} — пропущена",
|
"Строка {Row} заявки {Ticket} активна, но дата активации (Date) не заполнена — пропущена",
|
||||||
row.Id, row.TicketId);
|
row.Id, row.TicketId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -121,7 +116,7 @@ public sealed class ReminderService(
|
|||||||
// SLA строки перекрывает типовой. У строк, созданных до появления
|
// SLA строки перекрывает типовой. У строк, созданных до появления
|
||||||
// колонки, значения нет — тогда работает SLA типа заявки.
|
// колонки, значения нет — тогда работает SLA типа заявки.
|
||||||
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
var slaHours = row.SlaHours ?? type.SlaHours!.Value;
|
||||||
var deadline = activatedAt.Value.AddHours(slaHours);
|
var deadline = activatedAt.AddHours(slaHours);
|
||||||
if (deadline >= now) continue;
|
if (deadline >= now) continue;
|
||||||
|
|
||||||
reminders.Add(new ReminderDto(
|
reminders.Add(new ReminderDto(
|
||||||
@@ -137,51 +132,6 @@ public sealed class ReminderService(
|
|||||||
return reminders;
|
return reminders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Момент, с которого шаг считается активным, — начало отсчёта SLA.
|
|
||||||
///
|
|
||||||
/// Отдельной колонки «когда шаг стал активным» в списке нет (активация — это
|
|
||||||
/// точечное обновление статуса, дату оно не пишет), поэтому момент выводится
|
|
||||||
/// из соседних строк:
|
|
||||||
/// • первый шаг согласования — дата создания заявки;
|
|
||||||
/// • первый шаг исполнения — последнее решение по фазе согласования
|
|
||||||
/// (фаза исполнения активируется только когда согласование пройдено целиком);
|
|
||||||
/// • остальные шаги — последнее решение по предыдущему шагу СВОЕЙ фазы.
|
|
||||||
///
|
|
||||||
/// Везде именно MAX даты, а не «дата предыдущей строки»: шаг могут делить
|
|
||||||
/// несколько параллельных согласующих с одинаковым StepOrder, и следующий шаг
|
|
||||||
/// активируется только после решения последнего из них.
|
|
||||||
///
|
|
||||||
/// «Предыдущий шаг» ищется как ближайший меньший существующий StepOrder, а не
|
|
||||||
/// как order-1: в нумерации бывают пропуски (шаг удалили, шаг не подошёл по
|
|
||||||
/// условию появления).
|
|
||||||
/// </summary>
|
|
||||||
private static DateTimeOffset? ActivatedAt(
|
|
||||||
ApprovalRow row, IReadOnlyList<ApprovalRow> 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<ApprovalRow> 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
/// Ссылка ведёт в тот раздел портала, которому принадлежит просроченный шаг:
|
||||||
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
/// исполнителя незачем высаживать в «На согласовании». Сама заявка всё равно
|
||||||
@@ -207,7 +157,7 @@ public sealed class ReminderService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const string RowSelect =
|
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";
|
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000";
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
|
||||||
@@ -219,43 +169,6 @@ public sealed class ReminderService(
|
|||||||
return Parse(items);
|
return Parse(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
|
||||||
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var rows = new List<ApprovalRow>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Даты создания заявок — начало отсчёта для первого шага согласования.</summary>
|
|
||||||
private async Task<Dictionary<int, DateTimeOffset>> ReadCreatedAsync(
|
|
||||||
string siteUrl, string listUrl, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var created = new Dictionary<int, DateTimeOffset>();
|
|
||||||
|
|
||||||
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<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
private static List<ApprovalRow> Parse(IEnumerable<JsonElement> items) =>
|
||||||
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
items.Select(ApprovalRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-87
@@ -13,9 +13,11 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
|||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
|
/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки
|
||||||
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
|
/// (колонка Date — request-portal проставляет её в момент, когда строка
|
||||||
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
|
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
|
||||||
|
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
|
||||||
|
/// и проверяют вместо прежнего вывода даты из соседних строк.
|
||||||
///
|
///
|
||||||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
||||||
@@ -53,13 +55,12 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||||
|
|
||||||
private static object Row(
|
private static object Row(
|
||||||
int id, int ticketId, int stepOrder, string status, string phase,
|
int id, int ticketId, string status, string phase,
|
||||||
string? date = null, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
TicketId = ticketId,
|
TicketId = ticketId,
|
||||||
StepOrder = stepOrder,
|
|
||||||
ApprovalStatus = status,
|
ApprovalStatus = status,
|
||||||
Phase = phase,
|
Phase = phase,
|
||||||
Date = date,
|
Date = date,
|
||||||
@@ -68,8 +69,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
TansuSiteApprover = new { EMail = email }
|
TansuSiteApprover = new { EMail = email }
|
||||||
};
|
};
|
||||||
|
|
||||||
private ReminderService BuildService(
|
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
|
||||||
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
|
// мок отвечает так же: по значению статуса, а не по признаку решённости.
|
||||||
|
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
|
||||||
|
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
|
||||||
|
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
|
||||||
|
// а не тихо проигнорирует лишний запрос.
|
||||||
|
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
|
||||||
{
|
{
|
||||||
var sharePoint = new Mock<ISharePointClient>();
|
var sharePoint = new Mock<ISharePointClient>();
|
||||||
|
|
||||||
@@ -77,9 +83,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(DataListTitle);
|
.ReturnsAsync(DataListTitle);
|
||||||
|
|
||||||
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
|
var activeRows = rows
|
||||||
// отвечает так же: по значению статуса, а не по признаку решённости.
|
|
||||||
var activeRows = allRows
|
|
||||||
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
||||||
"На согласовании" or "На исполнении")
|
"На согласовании" or "На исполнении")
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -88,15 +92,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(activeRows.Select(Json).ToList());
|
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||||||
|
|
||||||
sharePoint
|
|
||||||
.Setup(x => x.GetItemsByListTitleAsync(
|
|
||||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(allRows.Select(Json).ToList());
|
|
||||||
|
|
||||||
sharePoint
|
|
||||||
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
|
|
||||||
|
|
||||||
return new ReminderService(
|
return new ReminderService(
|
||||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||||
sharePoint.Object,
|
sharePoint.Object,
|
||||||
@@ -104,14 +99,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
NullLogger<ReminderService>.Instance);
|
NullLogger<ReminderService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")]
|
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
|
||||||
public async Task FirstApprovalStep_CountsFromRequestCreated()
|
public async Task ActiveRow_CountsFromOwnDate()
|
||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24);
|
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
|
||||||
// Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
||||||
requestCreated: Now.AddHours(-48));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
@@ -127,70 +121,43 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
|
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
|
||||||
requestCreated: Now.AddHours(-2));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldBeEmpty();
|
result.Value.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")]
|
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
|
||||||
public async Task SecondStep_CountsFromPreviousStepDecision()
|
public async Task OtherRoutRows_AreIrrelevant()
|
||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10);
|
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
|
||||||
// Заявка создана давно, но предыдущий шаг решён 12 часов назад:
|
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
|
||||||
// при отсчёте от создания просрочка была бы огромной, верная — 2 часа.
|
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
|
||||||
|
// сервис их дату уже не использует: отсчёт идёт только от Date
|
||||||
|
// активной строки.
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[
|
[
|
||||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
Row(10, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
||||||
Row(11, 1, 2, "На согласовании", "Согласование", email: "second@tnsu.kz")
|
Row(11, 1, "На согласовании", "Согласование", date: Now.AddHours(-12).ToString("O"))
|
||||||
],
|
]);
|
||||||
requestCreated: Now.AddDays(-30));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
|
||||||
|
|
||||||
var reminder = result.Value.ShouldHaveSingleItem();
|
|
||||||
reminder.Email.ShouldBe("second@tnsu.kz");
|
|
||||||
reminder.HoursOverdue.ShouldBe(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact(DisplayName = "Параллельный предыдущий шаг: отсчёт от ПОСЛЕДНЕГО решения, а не от первого")]
|
|
||||||
public async Task ParallelPreviousStep_CountsFromLastDecision()
|
|
||||||
{
|
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla parallel", slaHours: 10);
|
|
||||||
// Двое делят StepOrder = 1. Следующий шаг активируется только после
|
|
||||||
// решения второго (-12 ч), а не первого (-40 ч).
|
|
||||||
var service = BuildService(listUrl,
|
|
||||||
[
|
|
||||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
|
||||||
Row(11, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
|
||||||
Row(12, 1, 2, "На согласовании", "Согласование", email: "next@tnsu.kz")
|
|
||||||
],
|
|
||||||
requestCreated: Now.AddDays(-30));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")]
|
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
||||||
public async Task FirstExecutionStep_CountsFromLastApprovalDecision()
|
public async Task ActiveRow_WithoutDate_IsSkipped()
|
||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5);
|
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[
|
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
|
||||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-30).ToString("O")),
|
|
||||||
Row(11, 1, 2, "Согласовано", "Согласование", date: Now.AddHours(-8).ToString("O")),
|
|
||||||
Row(12, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
|
||||||
],
|
|
||||||
requestCreated: Now.AddDays(-30));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
var reminder = result.Value.ShouldHaveSingleItem();
|
result.IsSuccess.ShouldBeTrue();
|
||||||
reminder.Email.ShouldBe("executor@tnsu.kz");
|
result.Value.ShouldBeEmpty();
|
||||||
reminder.HoursOverdue.ShouldBe(3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
|
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
|
||||||
@@ -199,8 +166,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100);
|
var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100);
|
||||||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1)],
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
|
||||||
requestCreated: Now.AddHours(-6));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
@@ -220,8 +186,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
|
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
|
||||||
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
|
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
|
||||||
// воспроизводит.
|
// воспроизводит.
|
||||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)],
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
|
||||||
requestCreated: Now.AddHours(-6));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
@@ -233,8 +198,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
|
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||||||
requestCreated: Now.AddHours(-6));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
@@ -249,8 +213,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
|
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[Row(10, 11, 1, "На согласовании", "Согласование")],
|
[Row(10, 11, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||||||
requestCreated: Now.AddHours(-6));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
@@ -264,11 +227,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
{
|
{
|
||||||
var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1);
|
var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1);
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[
|
[Row(10, 1, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
|
||||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
|
||||||
Row(11, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
|
||||||
],
|
|
||||||
requestCreated: Now.AddDays(-30));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
@@ -281,10 +240,9 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1);
|
var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1);
|
||||||
var service = BuildService(listUrl,
|
var service = BuildService(listUrl,
|
||||||
[
|
[
|
||||||
Row(10, 1, 1, "На согласовании", "Согласование"),
|
Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
||||||
Row(11, 1, 2, "Ждёт очереди", "Согласование", email: "waiting@tnsu.kz")
|
Row(11, 1, "Ждёт очереди", "Согласование", date: null, email: "waiting@tnsu.kz")
|
||||||
],
|
]);
|
||||||
requestCreated: Now.AddHours(-6));
|
|
||||||
|
|
||||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user