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
@@ -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;
});
};
}