Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/components/screens/RouteCopyModal.tsx
T
gorbenko.pavel 10c3057eff Несколько условий шага, объединяемых через И/ИЛИ
Раньше шаг маршрута мог нести ровно одно условие (field_name/operation/
value). Теперь условий может быть сколько угодно, а их объединение задаётся
явно — ConditionsLogic.And (все условия) или .Or (любое из условий).

Бэкенд:
- Новая сущность TemplateRouteStepCondition (дочерняя таблица
  template_route_step_conditions, FK на шаг, cascade delete) вместо трёх
  колонок на TemplateRouteStep. Миграция AddMultipleStepConditions переносит
  существующие условия шагов в новую таблицу перед удалением старых колонок,
  чтобы не потерять уже настроенные в деве/проде условия.
- TemplateRouteService.MatchesConditions: несколько условий шага объединяются
  по ConditionsLogic (Any/All); HasUnevaluatableManagerCondition проверяет
  условие на IsManager в списке условий, а не единственное поле.
- TemplateRouteStepService: дубликаты шагов теперь сравниваются по всему
  набору условий (без учёта порядка) и, если условий два и более, по логике
  их объединения — сравнение в памяти, а не в SQL, кандидатов на дубликат
  в пределах маршрута всегда мало.
- Валидация условия: раньше «все три поля или ни одного», теперь — в каждом
  элементе списка все три поля обязательны.

Фронтенд:
- RouteStepFormModal: условие — динамический список строк (добавить/удалить),
  у каждой строки свой набор допустимых операторов по типу колонки; селектор
  И/ИЛИ появляется, когда условий два и больше.
- describeStepCondition в таблице шагов склеивает все условия через « И »/« ИЛИ ».

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:56:36 +05:00

229 lines
9.2 KiB
TypeScript

import * as React from 'react';
import {
Company,
CreateTemplateRouteStepInput,
Department,
ProjectRole,
TemplateRoute,
TemplateRouteStep
} from '../../models';
import { texts } from '../../texts';
import { shortGuid } from '../../utils/guid';
import { useAsyncSubmit } from '../../hooks/useAsyncSubmit';
import { useRouteSteps } from '../../hooks/useRouteSteps';
import { ApprovalRouteApi } from '../../services/api';
import { ApiClientError } from '../../services/httpClient';
import { Alert, Checkbox, Field, FormGrid, FormModal, SearchableSelect } from '../ui';
export interface IRouteCopyModalProps {
api: ApprovalRouteApi;
/** Маршрут-источник: тип заявки у копии тот же, меняются только «координаты». */
source: TemplateRoute;
requestTypeName: string;
companies: Company[];
departments: Department[];
dictionaryLoading: boolean;
onCopied: () => void;
onClose: () => void;
}
interface IFormState {
companyId: string;
departmentId: string;
byProject: boolean;
requiresControl: boolean;
}
/** Тело для создания шага-копии: всё, кроме id/routeId, переносится как есть. */
function toStepInput(step: TemplateRouteStep): CreateTemplateRouteStepInput {
return {
orderNo: step.orderNo,
executorEmail: step.executorEmail,
position: step.position,
projectRole: step.projectRole,
headType: step.headType,
status: step.status,
allowReassignToOtherApprovers: step.allowReassignToOtherApprovers,
conditions: step.conditions,
conditionsLogic: step.conditionsLogic,
slaHours: step.slaHours,
askOverdueReason: step.askOverdueReason
};
}
/**
* Копирование маршрута вместе с шагами в пределах ОДНОГО типа заявки.
*
* Маршрут уникален по (тип заявки, компания, отдел, «по проекту») — см.
* HasDuplicateAsync в TemplateRouteService. Тип заявки у копии тот же, поэтому
* копия обязана отличаться хотя бы одним из остальных трёх полей; проверяем это
* до запроса, чтобы вместо 409 от бэкенда показать понятную причину.
*
* Шаги переносятся по одному. Если хоть один не создался, СОЗДАННЫЙ МАРШРУТ
* УДАЛЯЕТСЯ: наполовину скопированный маршрут хуже, чем отсутствующий — он бы
* молча резолвился с недостающими согласующими.
*/
export const RouteCopyModal: React.FC<IRouteCopyModalProps> = ({
api,
source,
requestTypeName,
companies,
departments,
dictionaryLoading,
onCopied,
onClose
}) => {
const [form, setForm] = React.useState<IFormState>({
companyId: source.companyId ?? '',
departmentId: source.departmentId ?? '',
byProject: source.byProject,
requiresControl: source.requiresControl
});
const steps = useRouteSteps(api, source.id);
const submit = useAsyncSubmit();
const departmentsForSelectedCompany = React.useMemo(
() => (form.companyId ? departments.filter(d => d.companyId === form.companyId) : []),
[departments, form.companyId]
);
const sourceCompany = source.companyId
? companies.find(c => c.id === source.companyId)?.name ?? shortGuid(source.companyId)
: texts.routeSteps.anyCompany;
const sourceDepartment = source.departmentId
? departments.find(d => d.id === source.departmentId)?.name ?? shortGuid(source.departmentId)
: texts.routeSteps.anyDepartment;
// Шаги с ролью в проекте бэкенд не примет в маршрут без признака «по проекту».
const projectRoleSteps = steps.items.filter(s => s.projectRole && s.projectRole !== ProjectRole.None).length;
const projectRoleBlocks = projectRoleSteps > 0 && !form.byProject;
// Копия должна отличаться от источника: тип заявки у них общий, значит
// различие обязано быть в компании, отделе или признаке «по проекту».
const sameAsSource =
(form.companyId || '') === (source.companyId ?? '') &&
(form.departmentId || '') === (source.departmentId ?? '') &&
form.byProject === source.byProject;
const submitForm = (): void => {
if (sameAsSource) {
submit.fail(texts.routes.validationCopySame);
return;
}
if (projectRoleBlocks) {
submit.fail(texts.routes.copyProjectRoleWarning(projectRoleSteps));
return;
}
const companyId = form.companyId.trim() ? form.companyId.trim() : null;
const departmentId = form.departmentId.trim() ? form.departmentId.trim() : null;
submit.run(async () => {
const created = await api.templateRoutes.create({
requestTypeId: source.requestTypeId,
companyId,
departmentId,
byProject: form.byProject,
requiresControl: form.requiresControl
});
// Последовательно, а не Promise.all: при ошибке важно знать, на каком шаге
// сорвались, а параллельные запросы к тому же дают гонку по orderNo в логах.
try {
for (const step of [...steps.items].sort((a, b) => a.orderNo - b.orderNo)) {
await api.templateRouteSteps.create(created.id, toStepInput(step)).catch((e: ApiClientError) => {
throw new Error(texts.routes.copyStepFailed(step.orderNo, e.message));
});
}
} catch (stepError) {
// Откат: маршрут без части шагов резолвился бы молча и неверно.
try {
await api.templateRoutes.remove(created.id);
} catch {
throw new Error(
`${(stepError as Error).message} ${texts.routes.copyRollbackFailed(created.id)}`
);
}
throw new Error(`${(stepError as Error).message} ${texts.routes.copyRolledBack}`);
}
onCopied();
onClose();
});
};
return (
<FormModal
title={texts.routes.copyTitle(source.id)}
error={submit.error}
isSaving={submit.isBusy}
submitLabel={texts.routes.copyAction}
savingLabel={texts.routes.copyInProgress}
onSubmit={submitForm}
onClose={onClose}
>
<Alert kind="ok">{texts.routes.copySource(requestTypeName, sourceCompany, sourceDepartment)}</Alert>
{steps.error && <Alert kind="err">{steps.error}</Alert>}
{!steps.error && steps.isLoading && <Alert kind="ok">{texts.routes.copyStepsLoading}</Alert>}
{!steps.error && !steps.isLoading && (
<Alert kind={steps.items.length > 0 ? 'ok' : 'warn'}>
{steps.items.length > 0 ? texts.routes.copyStepsCount(steps.items.length) : texts.routes.copyStepsEmpty}
</Alert>
)}
{projectRoleBlocks && <Alert kind="warn">{texts.routes.copyProjectRoleWarning(projectRoleSteps)}</Alert>}
<FormGrid>
<Field label={texts.routes.companyLabel} full hint={texts.routes.copyHint}>
<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 =>
// Как и в форме маршрута: смена компании обнуляет отдел — прежний
// принадлежал другой компании.
setForm({ ...form, companyId, departmentId: '' })
}
/>
</Field>
<Field
label={texts.routes.departmentLabel}
full
hint={form.companyId ? texts.routes.departmentHint : texts.routes.departmentHintNoCompany}
>
<SearchableSelect
options={departmentsForSelectedCompany.map(d => ({ value: d.id, label: d.name }))}
value={form.departmentId}
loading={dictionaryLoading}
disabled={!form.companyId}
allowClear
clearLabel={texts.routes.departmentPlaceholder}
placeholder={texts.routes.departmentPlaceholder}
searchPlaceholder={texts.routes.departmentSearchPlaceholder}
emptyMessage={texts.routes.departmentEmpty}
onChange={departmentId => setForm({ ...form, departmentId })}
/>
</Field>
<Field label={texts.routes.byProjectLabel}>
<Checkbox
label={texts.routes.byProjectCheckbox}
checked={form.byProject}
onChange={v => setForm({ ...form, byProject: v })}
/>
</Field>
<Field label={texts.routes.controlLabel}>
<Checkbox
label={texts.routes.controlCheckbox}
checked={form.requiresControl}
onChange={v => setForm({ ...form, requiresControl: v })}
/>
</Field>
</FormGrid>
</FormModal>
);
};