Редактирование заявок у конкретного пользователя (#14)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tnsu.ApprovalRoute.Api.Common;
|
||||
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Поиск шагов шаблонов маршрутов НЕ по конкретному маршруту, а сквозной, по всем
|
||||
/// маршрутам разом — отдельный top-level контроллер, а не вложенный под
|
||||
/// /api/template-routes/{routeId}/steps (тот же приём, что RequestTypeListsController
|
||||
/// отдельно от RequestTypesController).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/template-route-steps")]
|
||||
[Produces("application/json")]
|
||||
public sealed class TemplateRouteStepSearchController(TemplateRouteStepService service) : ControllerBase
|
||||
{
|
||||
/// <summary>Шаги ВСЕХ маршрутов с данным ExecutorEmail — для экрана «заменить согласующего everywhere».</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] string executorEmail, CancellationToken ct) =>
|
||||
(await service.ListByExecutorEmailAsync(executorEmail, ct)).ToActionResult();
|
||||
}
|
||||
@@ -40,20 +40,34 @@ public sealed class ReminderService(
|
||||
.Where(x => x.SlaHours != null && x.ListId != null && x.SiteUrl != null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Типы независимы (разные сайты/списки в SharePoint) — их обход
|
||||
// распараллелен, иначе время растёт линейно с числом типов: каждый тип
|
||||
// сам по себе делает несколько последовательных HTTP-запросов.
|
||||
var results = await Task.WhenAll(types.Select(type => GetOverdueForTypeSafeAsync(type, now, cancellationToken)));
|
||||
// Архивные партиции читаем ПОСЛЕДОВАТЕЛЬНО, одним DbContext — он не
|
||||
// потокобезопасен, и параллельный вызов GetArchivedAsync из нескольких
|
||||
// веток Task.WhenAll ниже валит его исключением ("A second operation
|
||||
// was started on this context instance before a previous operation
|
||||
// completed"), которое try/catch в GetOverdueForTypeSafeAsync тихо
|
||||
// проглатывает — часть напоминаний молча пропадала бы без всякой
|
||||
// видимой ошибки. Сама выборка — без HTTP, быстрая, последовательность
|
||||
// здесь не стоит ощутимого времени.
|
||||
var archivedByType = new Dictionary<int, IReadOnlyList<RequestListPartition>>();
|
||||
foreach (var type in types)
|
||||
archivedByType[type.Id] = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||
|
||||
// Дальше типы независимы (разные сайты/списки в SharePoint, БД уже не
|
||||
// трогают) — обход распараллелен, иначе время растёт линейно с числом
|
||||
// типов: каждый тип сам по себе делает несколько последовательных
|
||||
// HTTP-запросов.
|
||||
var results = await Task.WhenAll(
|
||||
types.Select(type => GetOverdueForTypeSafeAsync(type, archivedByType[type.Id], now, cancellationToken)));
|
||||
|
||||
return Result.Ok<IReadOnlyList<ReminderDto>>(results.SelectMany(r => r).ToList());
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeSafeAsync(
|
||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await GetOverdueForTypeAsync(type, now, cancellationToken);
|
||||
return await GetOverdueForTypeAsync(type, archived, now, cancellationToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -70,14 +84,13 @@ public sealed class ReminderService(
|
||||
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||||
/// новых заявок завели следующий список.
|
||||
///
|
||||
/// request_list_partitions хранит только архив (см. ListPartitionRegistry) —
|
||||
/// текущая партиция синтезируется отдельно и добавляется к архиву здесь же,
|
||||
/// а не читается из БД.
|
||||
/// archived — уже прочитан вызывающей стороной (GetOverdueAsync,
|
||||
/// последовательно для всех типов разом) — здесь БД больше не трогаем,
|
||||
/// только HTTP к SharePoint, поэтому вызов можно параллелить по типам.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
RequestType type, IReadOnlyList<RequestListPartition> archived, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||
|
||||
var partitions = archived
|
||||
|
||||
@@ -25,6 +25,29 @@ public sealed class TemplateRouteStepService(
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Шаги ВСЕХ маршрутов с данным конкретным исполнителем (ExecutorEmail) — для
|
||||
/// админского экрана «заменить согласующего everywhere» (см. ManageByUserScreen):
|
||||
/// шаги, где исполнитель задан должностью/ролью/типом руководителя, сюда не
|
||||
/// попадают — это не привязка к конкретному человеку.
|
||||
/// </summary>
|
||||
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListByExecutorEmailAsync(
|
||||
string executorEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(executorEmail))
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(Array.Empty<TemplateRouteStepDto>());
|
||||
|
||||
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||
.Where(x => x.ExecutorEmail != null && EF.Functions.ILike(x.ExecutorEmail, executorEmail))
|
||||
.OrderBy(x => x.RouteId).ThenBy(x => x.OrderNo)
|
||||
.Select(x => new TemplateRouteStepDto(
|
||||
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers,
|
||||
x.FieldName, x.Operation, x.Value, null, x.SlaHours, x.AskOverdueReason))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||
}
|
||||
|
||||
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||
|
||||
+40
@@ -511,4 +511,44 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
|
||||
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||
fetched.IsFailed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ListByExecutorEmailAsync находит шаги с данным исполнителем сразу по разным маршрутам, регистронезависимо")]
|
||||
public async Task ListByExecutorEmailAsync_FindsStepsAcrossRoutes_CaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var routeAId = await CreateRouteAsync("Route A for executor search", byProject: false);
|
||||
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
|
||||
await _service.CreateAsync(
|
||||
routeAId,
|
||||
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
|
||||
CancellationToken.None);
|
||||
await _service.CreateAsync(
|
||||
routeBId,
|
||||
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, null, null),
|
||||
CancellationToken.None);
|
||||
// Другой исполнитель — не должен попасть в выдачу.
|
||||
await _service.CreateAsync(
|
||||
routeAId,
|
||||
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
// Action
|
||||
var result = await _service.ListByExecutorEmailAsync("TARGET@EXAMPLE.KZ", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.Count.ShouldBe(2);
|
||||
result.Value.ShouldAllBe(x => x.RouteId == routeAId || x.RouteId == routeBId);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ListByExecutorEmailAsync с пустым email возвращает пустой список, а не ошибку")]
|
||||
public async Task ListByExecutorEmailAsync_EmptyEmail_ReturnsEmptyList()
|
||||
{
|
||||
// Action
|
||||
var result = await _service.ListByExecutorEmailAsync(string.Empty, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user