правка списка
This commit is contained in:
@@ -11,11 +11,14 @@ namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||
[Produces("application/json")]
|
||||
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("resolve")]
|
||||
// POST, а не GET: запрос принимает список условий (field_name/value) переменной длины,
|
||||
// который не ложится на плоскую query-строку без индексной эквилибристики. Единственный
|
||||
// потребитель — диагностический экран «Проверка маршрута» в админке, внешних вызовов нет.
|
||||
[HttpPost("resolve")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||
public async Task<IActionResult> Resolve([FromBody] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||
(await service.ResolveAsync(request, ct)).ToActionResult();
|
||||
|
||||
[HttpGet]
|
||||
|
||||
+5
-1
@@ -15,8 +15,12 @@ public sealed record ResolveTemplateRouteResultDto(
|
||||
bool RequiresControl,
|
||||
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||
|
||||
/// <summary>Значение поля списка SharePoint (field_name шага) для проверки условий шагов при resolve.</summary>
|
||||
public sealed record ResolveFieldValueDto(string FieldName, string Value);
|
||||
|
||||
public sealed record ResolveTemplateRouteRequest(
|
||||
int RequestTypeId,
|
||||
string? UserEmail,
|
||||
Guid? ProjectId,
|
||||
RouteStepStatus Status);
|
||||
RouteStepStatus Status,
|
||||
IReadOnlyList<ResolveFieldValueDto>? FieldValues = null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -165,8 +166,14 @@ public sealed class TemplateRouteService(
|
||||
if (request.ProjectId is { } projectId)
|
||||
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||||
|
||||
var fieldValues = (request.FieldValues ?? [])
|
||||
.Where(f => !string.IsNullOrWhiteSpace(f.FieldName))
|
||||
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues))
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.ToList();
|
||||
|
||||
@@ -240,6 +247,37 @@ public sealed class TemplateRouteService(
|
||||
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers, warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага (field_name/operation/value) против значений полей, переданных в resolve-запросе.
|
||||
/// Шаг без условия применяется всегда. Шаг с условием, для которого не передано значение поля
|
||||
/// (fieldValues его не содержит), считается неприменимым — при resolve мы моделируем конкретный
|
||||
/// сценарий, и отсутствие значения означает, что условие не выполняется.
|
||||
/// </summary>
|
||||
private static bool MatchesCondition(TemplateRouteStep step, IReadOnlyDictionary<string, string> fieldValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(step.FieldName) || step.Operation is null or ComparisonOperator.None)
|
||||
return true;
|
||||
|
||||
if (!fieldValues.TryGetValue(step.FieldName, out var actual) || string.IsNullOrWhiteSpace(actual))
|
||||
return false;
|
||||
|
||||
var expected = step.Value;
|
||||
return step.Operation switch
|
||||
{
|
||||
ComparisonOperator.Equal =>
|
||||
string.Equals(actual.Trim(), expected?.Trim(), StringComparison.OrdinalIgnoreCase) ||
|
||||
(TryParseNumber(actual, out var eqActual) && TryParseNumber(expected, out var eqExpected) && eqActual == eqExpected),
|
||||
ComparisonOperator.GreaterThan =>
|
||||
TryParseNumber(actual, out var gtActual) && TryParseNumber(expected, out var gtExpected) && gtActual > gtExpected,
|
||||
ComparisonOperator.LessThan =>
|
||||
TryParseNumber(actual, out var ltActual) && TryParseNumber(expected, out var ltExpected) && ltActual < ltExpected,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string? value, out double number) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||
|
||||
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -33,5 +33,10 @@ public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<Res
|
||||
public ResolveTemplateRouteRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||
RuleForEach(x => x.FieldValues).ChildRules(v =>
|
||||
{
|
||||
v.RuleFor(f => f.FieldName).NotEmpty().WithMessage("Имя поля обязательно.");
|
||||
v.RuleFor(f => f.Value).NotEmpty().WithMessage("Значение поля обязательно.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+115
@@ -40,6 +40,11 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
private async Task<int> CreateRequestTypeAsync(string name) =>
|
||||
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id;
|
||||
|
||||
private async Task<int> CreateRequestTypeWithListAsync(string name) =>
|
||||
(await _requestTypeService.CreateAsync(
|
||||
new CreateRequestTypeRequest(name, $"/{name}", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"),
|
||||
CancellationToken.None)).Value.Id;
|
||||
|
||||
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
|
||||
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
||||
{
|
||||
@@ -226,6 +231,116 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")]
|
||||
public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request satisfied");
|
||||
var route = await _service.CreateAsync(
|
||||
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||
CancellationToken.None);
|
||||
|
||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||
await stepService.CreateAsync(
|
||||
route.Value.Id,
|
||||
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Action
|
||||
var resolved = await _service.ResolveAsync(
|
||||
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
|
||||
[new ResolveFieldValueDto("Amount", "1500")]),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
resolved.IsSuccess.ShouldBeTrue();
|
||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("big-amount@tnsu.kz");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута исключает шаг с условием, когда значение поля его не удовлетворяет")]
|
||||
public async Task ResolveAsync_ConditionGreaterThan_NotSatisfiedByFieldValue_ExcludesStep()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request not satisfied");
|
||||
var route = await _service.CreateAsync(
|
||||
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||
CancellationToken.None);
|
||||
|
||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||
await stepService.CreateAsync(
|
||||
route.Value.Id,
|
||||
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
|
||||
CancellationToken.None);
|
||||
await stepService.CreateAsync(
|
||||
route.Value.Id,
|
||||
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
// Action
|
||||
var resolved = await _service.ResolveAsync(
|
||||
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
|
||||
[new ResolveFieldValueDto("Amount", "500")]),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
resolved.IsSuccess.ShouldBeTrue();
|
||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("always@tnsu.kz");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута исключает шаг с условием, если значение поля вообще не передано")]
|
||||
public async Task ResolveAsync_ConditionField_MissingFromRequest_ExcludesStep()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request missing value");
|
||||
var route = await _service.CreateAsync(
|
||||
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||
CancellationToken.None);
|
||||
|
||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||
await stepService.CreateAsync(
|
||||
route.Value.Id,
|
||||
new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Amount", ComparisonOperator.GreaterThan, "1000"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Action — fieldValues не переданы вовсе
|
||||
var resolved = await _service.ResolveAsync(
|
||||
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
resolved.IsSuccess.ShouldBeTrue();
|
||||
resolved.Value.Steps.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута с условием «равно» сравнивает значения как строки без учёта регистра")]
|
||||
public async Task ResolveAsync_ConditionEqual_MatchesCaseInsensitive()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeWithListAsync("Condition request equal");
|
||||
var route = await _service.CreateAsync(
|
||||
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||
CancellationToken.None);
|
||||
|
||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||
await stepService.CreateAsync(
|
||||
route.Value.Id,
|
||||
new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, "Priority", ComparisonOperator.Equal, "Urgent"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Action
|
||||
var resolved = await _service.ResolveAsync(
|
||||
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval,
|
||||
[new ResolveFieldValueDto("Priority", "URGENT")]),
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
resolved.IsSuccess.ShouldBeTrue();
|
||||
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
||||
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user