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

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 Id,
int RequestTypeId, int RequestTypeId,
Guid? DepartmentId, Guid? DepartmentId,
Guid CompanyId, Guid? CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record CreateTemplateRouteRequest( public sealed record CreateTemplateRouteRequest(
int RequestTypeId, int RequestTypeId,
Guid? DepartmentId, Guid? DepartmentId,
Guid CompanyId, Guid? CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record UpdateTemplateRouteRequest( public sealed record UpdateTemplateRouteRequest(
Guid? DepartmentId, Guid? DepartmentId,
Guid CompanyId, Guid? CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
@@ -140,22 +140,11 @@ public sealed class TemplateRouteService(
var byProject = request.ProjectId is not null; var byProject = request.ProjectId is not null;
var route = await db.TemplateRoutes.AsNoTracking() // Приоритет — маршрут с компанией инициатора; если для этой компании
.Include(x => x.Steps) // своего маршрута нет, берём запасной маршрут без указанной компании
.Where(x => // (тот же принцип, что уже применяется к отделу внутри FindRouteAsync).
x.RequestTypeId == request.RequestTypeId && var route = await FindRouteAsync(request.RequestTypeId, employee.CompanyId, employee.DepartmentId, byProject, cancellationToken)
x.CompanyId == employee.CompanyId && ?? await FindRouteAsync(request.RequestTypeId, null, employee.DepartmentId, byProject, cancellationToken);
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);
if (route is null) if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError( return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
@@ -186,8 +175,33 @@ public sealed class TemplateRouteService(
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RequiresControl, collapsedSteps)); 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( 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 => db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId && x.Id != excludeId &&
x.RequestTypeId == requestTypeId && x.RequestTypeId == requestTypeId &&
@@ -3,30 +3,10 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes; namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest> // Компания и отдел маршрута — оба опциональны, поэтому CreateTemplateRouteRequest/
{ // UpdateTemplateRouteRequest/PatchTemplateRouteRequest не нуждаются в отдельных
public CreateTemplateRouteRequestValidator() // валидаторах: маршрут без компании служит запасным вариантом, когда для
{ // конкретной компании инициатора своего маршрута нет (см. ResolveAsync).
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("Компания обязательна."));
}
}
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest> public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{ {
@@ -6,7 +6,7 @@ public class TemplateRoute
public int RequestTypeId { get; set; } public int RequestTypeId { get; set; }
public Guid? DepartmentId { get; set; } public Guid? DepartmentId { get; set; }
public Guid CompanyId { get; set; } public Guid? CompanyId { get; set; }
public bool ByProject { get; set; } public bool ByProject { get; set; }
public bool RequiresControl { get; set; } public bool RequiresControl { get; set; }
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(ApprovalRouteDbContext))] [DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260731093159_AddRequestTypeListNameSectionAndStepConditionFields")] [Migration("20260731111928_AddRequestTypeListNameSectionAndStepConditionFields")]
partial class AddRequestTypeListNameSectionAndStepConditionFields partial class AddRequestTypeListNameSectionAndStepConditionFields
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -81,7 +81,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
b.Property<Guid>("CompanyId") b.Property<Guid?>("CompanyId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id"); .HasColumnName("company_id");
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations; using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -14,6 +15,14 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "route_type", name: "route_type",
table: "template_routes"); table: "template_routes");
migrationBuilder.AlterColumn<Guid>(
name: "company_id",
table: "template_routes",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>( migrationBuilder.AddColumn<string>(
name: "field_name", name: "field_name",
table: "template_route_steps", table: "template_route_steps",
@@ -85,6 +94,16 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "site_url", name: "site_url",
table: "request_types"); 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>( migrationBuilder.AddColumn<string>(
name: "route_type", name: "route_type",
table: "template_routes", table: "template_routes",
@@ -78,7 +78,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
b.Property<Guid>("CompanyId") b.Property<Guid?>("CompanyId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id"); .HasColumnName("company_id");
@@ -583,6 +583,48 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.RouteId.ShouldBe(route.Value.Id); 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 = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")] [Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult() public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
{ {
@@ -39,7 +39,7 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s
} }
return { return {
requestTypeId: String(editing.requestTypeId), requestTypeId: String(editing.requestTypeId),
companyId: editing.companyId, companyId: editing.companyId ?? '',
departmentId: editing.departmentId ?? '', departmentId: editing.departmentId ?? '',
byProject: editing.byProject, byProject: editing.byProject,
requiresControl: editing.requiresControl requiresControl: editing.requiresControl
@@ -73,22 +73,19 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.fail(texts.routes.validationRequestType); submit.fail(texts.routes.validationRequestType);
return; return;
} }
if (!form.companyId.trim()) {
submit.fail(texts.routes.validationCompany);
return;
}
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null; const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
const request = editing const request = editing
? api.templateRoutes.update(editing.id, { ? api.templateRoutes.update(editing.id, {
companyId: form.companyId.trim(), companyId,
departmentId, departmentId,
byProject: form.byProject, byProject: form.byProject,
requiresControl: form.requiresControl requiresControl: form.requiresControl
}) })
: api.templateRoutes.create({ : api.templateRoutes.create({
requestTypeId: Number(form.requestTypeId), requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(), companyId,
departmentId, departmentId,
byProject: form.byProject, byProject: form.byProject,
requiresControl: form.requiresControl requiresControl: form.requiresControl
@@ -120,11 +117,13 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onChange={e => setForm({ ...form, requestTypeId: e.target.value })} onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/> />
</Field> </Field>
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}> <Field label={texts.routes.companyLabel} full hint={texts.routes.companyHint}>
<SearchableSelect <SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))} options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId} value={form.companyId}
loading={dictionaryLoading} loading={dictionaryLoading}
allowClear
clearLabel={texts.routes.companyClear}
placeholder={texts.routes.companyPlaceholder} placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder} searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId => onChange={companyId =>
@@ -92,7 +92,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
header: texts.routes.columnRequestType, header: texts.routes.columnRequestType,
render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell> render: r => <PrimaryCell>{requestTypeName(r.requestTypeId)}</PrimaryCell>
}, },
{ key: 'company', header: texts.routes.columnCompany, render: r => companyName(r.companyId) }, { key: 'company', header: texts.routes.columnCompany, render: r => (r.companyId ? companyName(r.companyId) : texts.common.empty) },
{ key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) }, { key: 'department', header: texts.routes.columnDepartment, render: r => (r.departmentId ? departmentName(r.departmentId) : texts.common.empty) },
{ {
key: 'byProject', key: 'byProject',
@@ -4,7 +4,8 @@ export interface TemplateRoute {
id: number; id: number;
requestTypeId: number; requestTypeId: number;
departmentId: string | null; departmentId: string | null;
companyId: string; /** Опционально — маршрут без компании служит запасным вариантом при resolve. */
companyId: string | null;
byProject: boolean; byProject: boolean;
requiresControl: boolean; requiresControl: boolean;
} }
@@ -12,7 +13,7 @@ export interface TemplateRoute {
export interface CreateTemplateRouteInput { export interface CreateTemplateRouteInput {
requestTypeId: number; requestTypeId: number;
departmentId: string | null; departmentId: string | null;
companyId: string; companyId: string | null;
byProject: boolean; byProject: boolean;
requiresControl: boolean; requiresControl: boolean;
} }
@@ -20,14 +21,14 @@ export interface CreateTemplateRouteInput {
// Тип заявки у существующего маршрута не меняется — его нет в update-контракте. // Тип заявки у существующего маршрута не меняется — его нет в update-контракте.
export interface UpdateTemplateRouteInput { export interface UpdateTemplateRouteInput {
departmentId: string | null; departmentId: string | null;
companyId: string; companyId: string | null;
byProject: boolean; byProject: boolean;
requiresControl: boolean; requiresControl: boolean;
} }
export interface PatchTemplateRouteInput { export interface PatchTemplateRouteInput {
departmentId?: string | null; departmentId?: string | null;
companyId?: string; companyId?: string | null;
byProject?: boolean; byProject?: boolean;
requiresControl?: boolean; requiresControl?: boolean;
} }
+3 -3
View File
@@ -152,9 +152,10 @@ export const texts = {
requestTypeLabel: 'Тип заявки', requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки', requestTypePlaceholder: 'Выберите тип заявки',
companyLabel: 'Компания', companyLabel: 'Компания',
companyHint: 'Справочник 1С (api.tnsu.kz).', companyHint: 'Необязательно — пусто означает запасной маршрут, если для компании инициатора своего маршрута нет.',
companyPlaceholder: 'Выберите компанию', companyPlaceholder: 'Без компании (запасной маршрут)',
companySearchPlaceholder: 'Поиск по названию компании...', companySearchPlaceholder: 'Поиск по названию компании...',
companyClear: 'Без компании (запасной маршрут)',
departmentLabel: 'Отдел', departmentLabel: 'Отдел',
departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.', departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.',
departmentHintNoCompany: 'Сначала выберите компанию.', departmentHintNoCompany: 'Сначала выберите компанию.',
@@ -166,7 +167,6 @@ export const texts = {
controlLabel: 'Контроль', controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения', controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.', validationRequestType: 'Выберите тип заявки.',
validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).',
deleteTitle: 'Удалить маршрут?', deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string => deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.` `Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`