feat: добавление согласующих и исполнителей в поданные заявки на подбор и приём

Алтынай Бултурукова (право canEditRoute) просила добавлять участников в уже
поданный маршрут. Новый согласующий или исполнитель встаёт в конец своего
этапа со статусом «Ждёт очереди» (дальше его можно поднять стрелками);
согласующего — пока идёт согласование, исполнителя — пока не завершено
исполнение, без дублей среди ещё не решивших. Заявка «трогается», чтобы
бэкенд выдал новому участнику права на элемент данных.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-09 12:16:18 +05:00
parent 79d2a59229
commit c80972469f
10 changed files with 334 additions and 14 deletions
+1
View File
@@ -19,6 +19,7 @@
| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` |
| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` |
| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` |
| Добавлять участников **в поданную заявку**: согласующего — пока идёт согласование, исполнителя — пока не завершено исполнение; встаёт в конец своего этапа со статусом «Ждёт очереди» | те же (`canEditRoute`) | email в коде | **Только** Подбор и Приём | `canEditRoute` + `shared/approval/routeStepAdd.ts` (правило) + `RecruitingSelectionView.tsx` / `RecruitingHireView.tsx` |
| Менять местами шаги **в поданной заявке** (и согласующих, и исполнителей): только соседние шаги одной фазы, оба «Ждут очереди»; активный и пройденные шаги не двигаются | те же (`canEditRoute`) | email в коде | **Только** Подбор и Приём | `canEditRoute` + `shared/approval/queuedStepMove.ts` (правило) + `RecruitingSelectionView.tsx` / `RecruitingHireView.tsx` |
| Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` |
| То же в поданной заявке: заменять и удалять невыполненных исполнителей, **в том числе активного и себя** | Исполнители заявки, у которых свой шаг исполнения ещё не выполнен | роль в маршруте (фаза «Исполнение»), без списка email | **Только** Заявка в отдел 1С | `forms/Ticket1C/useExecutorEditing.ts` |
@@ -36,6 +36,7 @@ import {
queuedStepMoveRule,
useQueuedStepMove
} from '../../shared/approval';
import { useRouteStepAdd } from '../../shared/approval/useRouteStepAdd';
import { navigate } from '../../shared/routing';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getAttachments } from '../../shared/attachments/attachments.service';
@@ -280,6 +281,43 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
setRouteError
);
// Выше ранних return (загрузка/ошибка): зовётся и из колбэков хуков ниже.
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined);
};
// Добавить согласующего/исполнителя в поданную заявку — то же право
// routeEditRights.ts; правило «в конец этапа, пока он не завершён» — routeStepAdd.ts.
// Новый участник обычно ждёт очереди; если стал активным — сообщаем ему.
const handleStepAdded = (activatedRowIds: number[]): void => {
setDecisionSeq((n) => n + 1);
if (activatedRowIds.length > 0) notify();
};
const handleAddApprover = useRouteStepAdd(
approverService,
itemId,
APPROVAL_PHASE.approval,
handleStepAdded,
setRouteError
);
const handleAddExecutor = useRouteStepAdd(
approverService,
itemId,
APPROVAL_PHASE.execution,
handleStepAdded,
setRouteError
);
// По статусу заявки: согласующих — пока идёт согласование, исполнителей —
// пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts).
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
const canAddExecutor =
canRemoveApprover && (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution);
useEffect(() => {
let alive = true;
setLoading(true);
@@ -321,15 +359,6 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
const sectionProps = { data, set, context, payrollEditable, hireDateEditable };
const notify = (): void => {
if (!requestTypeId) return;
notifyActiveStep(context, approval, itemId, {
requestTypeId,
dataListName: listId ? dataListTitleForListId(FORM_ID, listId) : dataListTitle(FORM_ID),
approvalListName: listId ? approvalListTitleForListId(FORM_ID, listId) : approvalListTitle(FORM_ID),
}).catch(() => undefined);
};
return (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
@@ -410,17 +439,21 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
onRemoveRow={handleRemoveApprover}
canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined}
onMoveRow={handleMoveStep}
onAddRow={canAddApprover ? handleAddApprover : undefined}
/>
{!executors.loading && executors.rows.length > 0 && (
{/* Без исполнителей блок показываем, только если их можно добавить. */}
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
canMoveRow={canRemoveApprover ? queuedStepMoveRule(executors.rows) : undefined}
onMoveRow={handleMoveStep}
onAddRow={canAddExecutor ? handleAddExecutor : undefined}
/>
)}
</div>
@@ -35,6 +35,7 @@ import {
queuedStepMoveRule,
useQueuedStepMove
} from '../../shared/approval';
import { useRouteStepAdd } from '../../shared/approval/useRouteStepAdd';
import { getAttachments } from '../../shared/attachments/attachments.service';
import { useAttachmentEditor } from '../../shared/attachments/useAttachmentEditor';
import { EditableAttachments } from '../../shared/attachments/EditableAttachments';
@@ -267,6 +268,33 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
setRouteError
);
// Добавить согласующего/исполнителя в поданную заявку — то же право
// routeEditRights.ts; правило «в конец этапа, пока он не завершён» — routeStepAdd.ts.
// Новый участник обычно ждёт очереди; если стал активным — сообщаем ему.
const handleStepAdded = (activatedRowIds: number[]): void => {
setDecisionSeq((n) => n + 1);
activatedRowIds.forEach((id) => notify(id));
};
const handleAddApprover = useRouteStepAdd(
approverService,
itemId,
APPROVAL_PHASE.approval,
handleStepAdded,
setRouteError
);
const handleAddExecutor = useRouteStepAdd(
approverService,
itemId,
APPROVAL_PHASE.execution,
handleStepAdded,
setRouteError
);
// По статусу заявки: согласующих — пока идёт согласование, исполнителей —
// пока не завершено исполнение. Сервер перепроверит по строкам (routeStepAdd.ts).
const canAddApprover = canRemoveApprover && status === REQUEST_STATUS.onApproval;
const canAddExecutor =
canRemoveApprover && (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution);
useEffect(() => {
let alive = true;
setLoading(true);
@@ -371,15 +399,19 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
onRemoveRow={handleRemoveApprover}
canMoveRow={canRemoveApprover ? queuedStepMoveRule(approvers.rows) : undefined}
onMoveRow={handleMoveStep}
onAddRow={canAddApprover ? handleAddApprover : undefined}
/>
{!executors.loading && executors.rows.length > 0 && (
{/* Без исполнителей блок показываем, только если их можно добавить. */}
{!executors.loading && (executors.rows.length > 0 || canAddExecutor) && (
<ApprovalMatrix
editor={executors}
search={(q) => approval.searchUsers(q)}
title="Исполнение"
roleLabel="Исполнитель"
emptyText="Исполнители не назначены."
pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…"
onAddRow={canAddExecutor ? handleAddExecutor : undefined}
canReplaceRow={canRemoveApprover ? isPendingExecutor : undefined}
onReplaceRow={handleReplaceExecutor}
canRemoveRow={
@@ -315,6 +315,12 @@ export class ApprovalListService {
await execute();
}
// Id пользователя сайта по email (ensureUser) — чтобы сравнить нового участника
// с уже стоящими в маршруте (см. ApproverService.addStep).
public async getUserIdByEmail(email: string): Promise<number> {
return this.resolveUserId(email);
}
// Добавляет одну строку согласования с заданным Order (для делегата).
// delegatedById — Id пользователя, который выполнил переназначение (не строки
// из маршрута — у них делегата нет). Он сохраняет право переназначить эту
@@ -9,6 +9,11 @@ import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents';
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
import { planStepAdd } from './routeStepAdd';
function isActiveStatus(status: string): boolean {
return status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.onExecution;
}
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
@@ -165,6 +170,45 @@ export class ApproverService {
emitRequestsChanged();
}
/**
* Добавить участника в поданную заявку: согласующего или исполнителя — в
* КОНЕЦ его этапа со статусом «Ждёт очереди» (право — routeEditRights.ts,
* правило — routeStepAdd.ts). Строки перечитываются перед записью.
*
* Заявку «трогаем» (touchRequest): новый участник должен получить права на
* элемент данных, иначе откроет её с «Элемент не найден».
*
* Возвращает Id строк, ставших активными (обычно пусто — активный шаг уже
* есть); им нужно оповещение (отправляет вызывающая сторона).
*/
public async addStep(requestId: number, phase: string, user: ISiteUser): Promise<number[]> {
const [rows, approverId] = await Promise.all([
this.approval.getForRequest(requestId),
this.approval.getUserIdByEmail(user.email),
]);
const plan = planStepAdd(rows, phase, approverId);
if (!plan.ok) throw new Error(plan.error);
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
await this.approval.addApproverRow(
requestId,
{ email: user.email, position, allowReassign: false },
plan.order,
REQUEST_STATUS.queued,
phase
);
// На случай, если активного шага почему-то не было: очередь сама подхватит
// нового участника. При обычном ходе заявки ничего не меняется.
const activeBefore = new Set(rows.filter((r) => isActiveStatus(r.status)).map((r) => r.id));
await this.approval.activateNextQueued(requestId);
const rowsAfter = await this.approval.getForRequest(requestId);
await this.touchRequest(requestId);
emitRequestsChanged();
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
}
/**
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
@@ -14,7 +14,8 @@ import { effectiveUserEmail } from '../impersonation';
* Реферальная рекомендация) — каждая форма сама решает, передавать ли
* canRemoveRow в ApprovalMatrix; здесь только сама проверка личности.
* В поданных подборе и приёме это же право даёт менять местами ждущие шаги
* (canMoveRow, правило — queuedStepMove.ts).
* (canMoveRow, правило — queuedStepMove.ts) и добавлять согласующих/исполнителей
* в конец их этапа (onAddRow, правило — routeStepAdd.ts).
*/
const ROUTE_EDITOR_EMAILS: ReadonlySet<string> = new Set([
'a.bulturukova@tnsu.kz', // Алтынай Бултурукова
@@ -0,0 +1,71 @@
import { IStepAddRow, planStepAdd, STEP_ADD_ERRORS } from './routeStepAdd';
const APPROVAL = 'Согласование';
const EXECUTION = 'Исполнение';
const QUEUED = 'Ждёт очереди';
const ON_APPROVAL = 'На согласовании';
const ON_EXECUTION = 'На исполнении';
const APPROVED = 'Согласовано';
const EXECUTED = 'Выполнено';
const RETURNED = 'Возвращена на доработку';
function row(approverId: number, order: number, status: string, phase = APPROVAL): IStepAddRow {
return { approverId, order, status, phase };
}
describe('planStepAdd — согласующий', () => {
it('встаёт после последнего согласующего, перед исполнителями', () => {
const rows = [row(1, 1, APPROVED), row(2, 2, ON_APPROVAL), row(3, 3, QUEUED), row(9, 4, QUEUED, EXECUTION)];
expect(planStepAdd(rows, APPROVAL, 5)).toEqual({ ok: true, order: 3.5 });
});
it('без исполнителей — следующий номер', () => {
expect(planStepAdd([row(1, 1, ON_APPROVAL)], APPROVAL, 5)).toEqual({ ok: true, order: 2 });
});
it('нельзя, если согласование пройдено', () => {
const rows = [row(1, 1, APPROVED), row(9, 2, ON_EXECUTION, EXECUTION)];
expect(planStepAdd(rows, APPROVAL, 5)).toEqual({ ok: false, error: STEP_ADD_ERRORS.approvalDone });
});
it('нельзя, если заявка возвращена на доработку', () => {
const rows = [row(1, 1, RETURNED), row(2, 2, QUEUED)];
expect(planStepAdd(rows, APPROVAL, 5)).toEqual({ ok: false, error: STEP_ADD_ERRORS.stopped });
});
it('дубль — только среди ещё не решивших; уже согласовавшего можно добавить повторно', () => {
const rows = [row(1, 1, APPROVED), row(2, 2, ON_APPROVAL)];
expect(planStepAdd(rows, APPROVAL, 2)).toEqual({ ok: false, error: STEP_ADD_ERRORS.duplicateApprover });
expect(planStepAdd(rows, APPROVAL, 1)).toEqual({ ok: true, order: 3 });
});
});
describe('planStepAdd — исполнитель', () => {
it('в конец маршрута, пока идёт согласование', () => {
const rows = [row(1, 1, ON_APPROVAL), row(9, 2, QUEUED, EXECUTION)];
expect(planStepAdd(rows, EXECUTION, 5)).toEqual({ ok: true, order: 3 });
});
it('можно и без исполнителей в маршруте, пока идёт согласование', () => {
expect(planStepAdd([row(1, 1, ON_APPROVAL)], EXECUTION, 5)).toEqual({ ok: true, order: 2 });
});
it('можно во время исполнения', () => {
const rows = [row(1, 1, APPROVED), row(9, 2, ON_EXECUTION, EXECUTION)];
expect(planStepAdd(rows, EXECUTION, 5)).toEqual({ ok: true, order: 3 });
});
it('нельзя, если исполнение завершено или исполнителей не было и заявка согласована', () => {
const closed = [row(1, 1, APPROVED), row(9, 2, EXECUTED, EXECUTION)];
expect(planStepAdd(closed, EXECUTION, 5)).toEqual({ ok: false, error: STEP_ADD_ERRORS.executionDone });
expect(planStepAdd([row(1, 1, APPROVED)], EXECUTION, 5)).toEqual({
ok: false,
error: STEP_ADD_ERRORS.executionDone,
});
});
it('дубль среди ещё не выполнивших', () => {
const rows = [row(1, 1, ON_APPROVAL), row(9, 2, QUEUED, EXECUTION)];
expect(planStepAdd(rows, EXECUTION, 9)).toEqual({ ok: false, error: STEP_ADD_ERRORS.duplicateExecutor });
});
});
@@ -0,0 +1,86 @@
import { APPROVAL_PHASE, REQUEST_STATUS } from './models/statuses';
/**
* Добавление участника в маршрут УЖЕ ПОДАННОЙ заявки (право — routeEditRights.ts).
* Чистая функция без SharePoint: решает, можно ли добавить, и с каким StepOrder.
* Выполняет ApproverService.addStep.
*
* Правило (согласовано с владельцем): новый участник встаёт в КОНЕЦ своего этапа
* со статусом «Ждёт очереди», дальше его можно поднять стрелками
* (queuedStepMove.ts). Активный шаг не трогается.
*/
/** То, что нужно о строке маршрута; подходит IApprovalRow. */
export interface IStepAddRow {
status: string;
order: number;
phase?: string;
approverId: number;
}
export type StepAddPlan = { ok: true; order: number } | { ok: false; error: string };
export const STEP_ADD_ERRORS = {
stopped: 'Заявка отклонена или возвращена на доработку — маршрут сейчас не меняется.',
approvalDone: 'Согласование уже пройдено — добавить согласующего нельзя.',
executionDone: 'Исполнение уже завершено — добавить исполнителя нельзя.',
duplicateApprover: 'Этот сотрудник уже есть среди согласующих, которые ещё не решили.',
duplicateExecutor: 'Этот сотрудник уже есть среди исполнителей, которые ещё не выполнили.',
} as const;
// Пустая фаза у старых строк — «Согласование» (см. getForRequest).
function isExecution(row: IStepAddRow): boolean {
return row.phase === APPROVAL_PHASE.execution;
}
function maxOrder(rows: IStepAddRow[]): number {
return rows.reduce((max, r) => Math.max(max, r.order), 0);
}
/** Можно ли добавить человека approverId в этап phase, и с каким StepOrder. */
export function planStepAdd(rows: IStepAddRow[], phase: string, approverId: number): StepAddPlan {
const approvalRows = rows.filter((r) => !isExecution(r));
const execRows = rows.filter(isExecution);
const stopped = approvalRows.some(
(r) => r.status === REQUEST_STATUS.rejected || r.status === REQUEST_STATUS.returned
);
if (stopped) return { ok: false, error: STEP_ADD_ERRORS.stopped };
const approvalDone = approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
if (phase === APPROVAL_PHASE.execution) {
// Согласование пройдено и исполнять уже нечего — заявка в финальном статусе
// («Согласовано» без исполнителей или «Закрыта»): переоткрывать её так нельзя.
const executionDone = execRows.every((r) => r.status === REQUEST_STATUS.executed);
if (approvalDone && executionDone) return { ok: false, error: STEP_ADD_ERRORS.executionDone };
const duplicate = execRows.some(
(r) =>
r.approverId === approverId &&
(r.status === REQUEST_STATUS.queued || r.status === REQUEST_STATUS.onExecution)
);
if (duplicate) return { ok: false, error: STEP_ADD_ERRORS.duplicateExecutor };
// Исполнители идут после всех строк маршрута.
return { ok: true, order: maxOrder(rows) + 1 };
}
if (approvalDone) return { ok: false, error: STEP_ADD_ERRORS.approvalDone };
// Тот, кто уже согласовал, может понадобиться повторно — дублем считаем только ещё не решивших.
const duplicate = approvalRows.some(
(r) =>
r.approverId === approverId &&
(r.status === REQUEST_STATUS.queued || r.status === REQUEST_STATUS.onApproval)
);
if (duplicate) return { ok: false, error: STEP_ADD_ERRORS.duplicateApprover };
// После последнего согласующего, но перед первым исполнителем — дробным
// order, как у делегатов, чтобы не перенумеровывать исполнителей. Очередь
// активирует шаги в пределах фазы, так что это лишь порядок показа.
const lastApproval = maxOrder(approvalRows);
const firstExec = execRows.length > 0 ? Math.min(...execRows.map((r) => r.order)) : undefined;
const order = firstExec !== undefined && firstExec > lastApproval ? (lastApproval + firstExec) / 2 : lastApproval + 1;
return { ok: true, order };
}
@@ -63,6 +63,12 @@ export interface IApprovalMatrixProps {
canMoveRow?: (row: IMatrixRow, direction: StepMoveDirection) => boolean;
/** Сдвинуть шаг на сервере (задаётся вместе с canMoveRow, см. useQueuedStepMove). */
onMoveRow?: (row: IMatrixRow, direction: StepMoveDirection) => void;
/**
* Добавить участника в УЖЕ ПОДАННУЮ заявку (с записью на сервер, см.
* useRouteStepAdd). Задан — под таблицей пикер, как в черновике, даже вне
* editable. В черновике не нужен: там пикер добавляет строку локально.
*/
onAddRow?: (user: ISiteUser) => void;
}
/** Таблица маршрута (согласование или исполнение) + (если editable) правка состава. */
@@ -81,6 +87,7 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
onReplaceRow,
canMoveRow,
onMoveRow,
onAddRow,
}) => {
const { rows, loading, error, editable, blocks, retry } = editor;
// Строка, для которой открыт выбор замены (ReplaceRowPanel); undefined — закрыт.
@@ -368,9 +375,13 @@ export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({
onCancel={() => setReplacingKey(undefined)}
/>
)}
{editable && !waiting && !loading && (
{(editable || onAddRow) && !waiting && !loading && (
<div className={styles.matrixPicker}>
<UserPicker search={search} onPick={addApprover} placeholder={pickerPlaceholder} />
<UserPicker
search={search}
onPick={editable || !onAddRow ? addApprover : onAddRow}
placeholder={pickerPlaceholder}
/>
</div>
)}
</div>
@@ -0,0 +1,35 @@
import { useRef } from 'react';
import { friendlyErrorMessage } from '../diagnostics';
import { ApproverService } from './approverDecision.service';
import { ISiteUser } from './models/ISiteUser';
/**
* Добавление участника в поданную заявку из ApprovalMatrix (onAddRow) — общая
* обвязка форм просмотра (RecruitingHireView, RecruitingSelectionView), как
* useQueuedStepMove. Пока одно добавление пишется, следующие выборы
* игнорируются: двойной выбор в пикере добавил бы человека дважды.
*
* onAdded получает Id строк, ставших активными, — им нужно оповещение.
*/
export function useRouteStepAdd(
service: ApproverService,
requestId: number,
phase: string,
onAdded: (activatedRowIds: number[]) => void,
onError: (message: string | undefined) => void
): (user: ISiteUser) => void {
const isAddingRef = useRef<boolean>(false);
return (user) => {
if (isAddingRef.current) return;
isAddingRef.current = true;
onError(undefined);
service
.addStep(requestId, phase, user)
.then(onAdded)
.catch((e) => onError(friendlyErrorMessage(e)))
.finally(() => {
isAddingRef.current = false;
});
};
}