Новый виды условных шагов #6

Merged
gorbenko.pavel merged 14 commits from newSteps into main 2026-07-31 12:15:15 +00:00
12 changed files with 120 additions and 65 deletions
Showing only changes of commit 14b25bf8cc - Show all commits
@@ -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()
{
@@ -39,7 +39,7 @@ function toFormState(editing: TemplateRoute | undefined, initialRequestTypeId: s
}
return {
requestTypeId: String(editing.requestTypeId),
companyId: editing.companyId,
companyId: editing.companyId ?? '',
departmentId: editing.departmentId ?? '',
byProject: editing.byProject,
requiresControl: editing.requiresControl
@@ -73,22 +73,19 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
submit.fail(texts.routes.validationRequestType);
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 request = editing
? api.templateRoutes.update(editing.id, {
companyId: form.companyId.trim(),
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
})
: api.templateRoutes.create({
requestTypeId: Number(form.requestTypeId),
companyId: form.companyId.trim(),
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
@@ -120,11 +117,13 @@ export const RouteFormModal: React.FC<IRouteFormModalProps> = ({
onChange={e => setForm({ ...form, requestTypeId: e.target.value })}
/>
</Field>
<Field label={texts.routes.companyLabel} required full hint={texts.routes.companyHint}>
<Field label={texts.routes.companyLabel} full hint={texts.routes.companyHint}>
<SearchableSelect
options={companies.map(c => ({ value: c.id, label: c.name }))}
value={form.companyId}
loading={dictionaryLoading}
allowClear
clearLabel={texts.routes.companyClear}
placeholder={texts.routes.companyPlaceholder}
searchPlaceholder={texts.routes.companySearchPlaceholder}
onChange={companyId =>
@@ -92,7 +92,7 @@ export const RoutesScreen: React.FC<IRoutesScreenProps> = ({ api, dictionary, on
header: texts.routes.columnRequestType,
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: 'byProject',
@@ -4,7 +4,8 @@ export interface TemplateRoute {
id: number;
requestTypeId: number;
departmentId: string | null;
companyId: string;
/** Опционально — маршрут без компании служит запасным вариантом при resolve. */
companyId: string | null;
byProject: boolean;
requiresControl: boolean;
}
@@ -12,7 +13,7 @@ export interface TemplateRoute {
export interface CreateTemplateRouteInput {
requestTypeId: number;
departmentId: string | null;
companyId: string;
companyId: string | null;
byProject: boolean;
requiresControl: boolean;
}
@@ -20,14 +21,14 @@ export interface CreateTemplateRouteInput {
// Тип заявки у существующего маршрута не меняется — его нет в update-контракте.
export interface UpdateTemplateRouteInput {
departmentId: string | null;
companyId: string;
companyId: string | null;
byProject: boolean;
requiresControl: boolean;
}
export interface PatchTemplateRouteInput {
departmentId?: string | null;
companyId?: string;
companyId?: string | null;
byProject?: boolean;
requiresControl?: boolean;
}
+3 -3
View File
@@ -152,9 +152,10 @@ export const texts = {
requestTypeLabel: 'Тип заявки',
requestTypePlaceholder: 'Выберите тип заявки',
companyLabel: 'Компания',
companyHint: 'Справочник 1С (api.tnsu.kz).',
companyPlaceholder: 'Выберите компанию',
companyHint: 'Необязательно — пусто означает запасной маршрут, если для компании инициатора своего маршрута нет.',
companyPlaceholder: 'Без компании (запасной маршрут)',
companySearchPlaceholder: 'Поиск по названию компании...',
companyClear: 'Без компании (запасной маршрут)',
departmentLabel: 'Отдел',
departmentHint: 'Необязательно — пусто означает маршрут «по умолчанию» для всей компании.',
departmentHintNoCompany: 'Сначала выберите компанию.',
@@ -166,7 +167,6 @@ export const texts = {
controlLabel: 'Контроль',
controlCheckbox: 'Требуется отдельный контроль исполнения',
validationRequestType: 'Выберите тип заявки.',
validationCompany: 'Компания обязательна (Guid из справочника api.tnsu.kz).',
deleteTitle: 'Удалить маршрут?',
deleteText: (routeId: number): string =>
`Маршрут #${routeId} и все его шаги будут удалены безвозвратно.`