Редактирование заявок у конкретного пользователя (#14)
Tests / frontend-test (push) Successful in 45s
Tests / backend-test (push) Successful in 7m18s

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:
2026-08-19 12:58:07 +00:00
parent 82512eb1db
commit 1ee1b71e3e
36 changed files with 1862 additions and 333 deletions
@@ -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()
@@ -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();
}
}