компания необзятаельна

This commit is contained in:
gorbenko.pavel
2026-07-31 16:22:07 +05:00
parent 58258b0126
commit 14b25bf8cc
12 changed files with 120 additions and 65 deletions
@@ -4,20 +4,20 @@ public sealed record TemplateRouteDto(
int Id,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record CreateTemplateRouteRequest(
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record UpdateTemplateRouteRequest(
Guid? DepartmentId,
Guid CompanyId,
Guid? CompanyId,
bool ByProject,
bool RequiresControl);
@@ -140,22 +140,11 @@ public sealed class TemplateRouteService(
var byProject = request.ProjectId is not null;
var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == employee.DepartmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
// Приоритет — маршрут с компанией инициатора; если для этой компании
// своего маршрута нет, берём запасной маршрут без указанной компании
// (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
@@ -186,8 +175,33 @@ public sealed class TemplateRouteService(
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps));
}
/// <summary>
/// Маршрут для компании/отдела инициатора с фолбэком по отделу: сначала точное
/// совпадение отдела, если такого маршрута нет — запасной маршрут без отдела
/// (для той же компании). Компания при этом фиксирована — фолбэк по ней делает
/// вызывающий код (см. ResolveAsync: сначала с companyId сотрудника, затем null).
/// </summary>
private async Task<TemplateRoute?> FindRouteAsync(
int requestTypeId, Guid? companyId, Guid? departmentId, bool byProject, CancellationToken cancellationToken) =>
await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId &&
x.ByProject == byProject &&
x.DepartmentId == departmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == requestTypeId &&
x.CompanyId == companyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
private Task<bool> HasDuplicateAsync(
int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
int requestTypeId, Guid? departmentId, Guid? companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId &&
x.RequestTypeId == requestTypeId &&
@@ -3,30 +3,10 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest>
{
public CreateTemplateRouteRequestValidator()
{
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
}
}
public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<UpdateTemplateRouteRequest>
{
public UpdateTemplateRouteRequestValidator()
{
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
}
}
public sealed class PatchTemplateRouteRequestValidator : AbstractValidator<PatchTemplateRouteRequest>
{
public PatchTemplateRouteRequestValidator()
{
When(x => x.CompanyId is not null, () =>
RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна."));
}
}
// Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/
// UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных
// валидаторах: маршрут без компании служит запасным вариантом, когда для
// конкретной компании инициатора своего маршрута нет (см. ResolveAsync).
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{
@@ -6,7 +6,7 @@ public class TemplateRoute
public int RequestTypeId { get; set; }
public Guid? DepartmentId { get; set; }
public Guid CompanyId { get; set; }
public Guid? CompanyId { get; set; }
public bool ByProject { get; set; }
public bool RequiresControl { get; set; }
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260731093159_AddRequestTypeListNameSectionAndStepConditionFields")]
[Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields
{
/// <inheritdoc />
@@ -81,7 +81,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid>("CompanyId")
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -14,6 +15,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "route_type",
table: "template_routes");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "field_name",
table: "template_route_steps",
@@ -85,6 +94,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "site_url",
table: "request_types");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "route_type",
table: "template_routes",
@@ -78,7 +78,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean")
.HasColumnName("by_project");
b.Property<Guid>("CompanyId")
b.Property<Guid?>("CompanyId")
.HasColumnType("uuid")
.HasColumnName("company_id");
@@ -583,6 +583,48 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.RouteId.ShouldBe(route.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута без компании инициатора использует запасной маршрут без указанной компании")]
public async Task ResolveAsync_NoCompanyRoute_FallsBackToRouteWithoutCompany()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("No company route request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId: null, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(route.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута предпочитает маршрут с компанией инициатора маршруту без компании")]
public async Task ResolveAsync_RouteWithCompanyExists_PreferredOverRouteWithoutCompany()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Company priority request");
var companyWideRoute = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId: null, ByProject: false, RequiresControl: false),
CancellationToken.None);
var companySpecificRoute = await _service.CreateAsync(
new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(companySpecificRoute.Value.Id);
resolved.Value.RouteId.ShouldNotBe(companyWideRoute.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
{