feat: замена и удаление исполнителей в заявке на подбор

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

Остальные формы не затронуты: кнопки включаются пропсами ApprovalMatrix
(canReplaceRow/onReplaceRow) только в RecruitingSelectionView.
notifyActiveStep получил необязательный rowId, чтобы на параллельном
шаге письмо ушло именно новому исполнителю.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-28 16:29:15 +05:00
parent be8177e8ae
commit 247f614885
7 changed files with 242 additions and 14 deletions
@@ -140,6 +140,77 @@ export class ApproverService {
emitRequestsChanged();
}
/**
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
* не трогаем (иначе можно «переоткрыть» закрытую заявку). Бросает с текстом
* для пользователя, если шаг не подходит.
*/
private editableExecutionRow(rows: IApprovalRow[], rowId: number): IApprovalRow {
const row = rows.filter((r) => r.id === rowId)[0];
if (!row || row.phase !== APPROVAL_PHASE.execution) {
throw new Error('Шаг не найден среди исполнителей — обновите заявку.');
}
if (row.status !== REQUEST_STATUS.queued && row.status !== REQUEST_STATUS.onExecution) {
throw new Error('Шаг уже выполнен — заменить или удалить его нельзя.');
}
return row;
}
/**
* Заменить исполнителя на шаге фазы «Исполнение» — в т.ч. активном. Шаг
* остаётся тем же (порядок, статус), меняется только человек. Подключено
* только в «Заявке на подбор» (см. RecruitingSelectionView), право — по
* routeEditRights.ts.
*
* Возвращает, был ли шаг активным: тогда заявка сразу у нового исполнителя,
* и ему нужно отправить оповещение (это делает вызывающая сторона).
*/
public async replaceExecutionStep(requestId: number, rowId: number, user: ISiteUser): Promise<boolean> {
const rows = await this.approval.getForRequest(requestId);
const row = this.editableExecutionRow(rows, rowId);
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
await this.approval.replaceApprover(rowId, { email: user.email, position });
emitRequestsChanged();
return row.status === REQUEST_STATUS.onExecution;
}
/**
* Удалить исполнителя с шага фазы «Исполнение» — в т.ч. активного. Если
* удалён активный, очередь переходит к следующему исполнителю.
*
* Последнего невыполненного исполнителя удалить нельзя — только заменить: без
* него все оставшиеся шаги окажутся выполненными (или их не останется вовсе),
* и computeRequestStatus закрыл бы заявку, хотя её никто не исполнил.
*
* Возвращает Id строк, ставших активными после удаления, — им нужно
* оповещение (отправляет вызывающая сторона).
*/
public async deleteExecutionStep(requestId: number, rowId: number): Promise<number[]> {
const rows = await this.approval.getForRequest(requestId);
this.editableExecutionRow(rows, rowId);
const othersPending = rows.filter(
(r) =>
r.id !== rowId &&
r.phase === APPROVAL_PHASE.execution &&
(r.status === REQUEST_STATUS.queued || r.status === REQUEST_STATUS.onExecution)
);
if (othersPending.length === 0) {
throw new Error('Это последний исполнитель заявки — его можно только заменить.');
}
const activeBefore = new Set(rows.filter((r) => r.status === REQUEST_STATUS.onExecution).map((r) => r.id));
await this.approval.deleteRow(rowId);
await this.approval.activateNextQueued(requestId);
const rowsAfter = await this.approval.getForRequest(requestId);
emitRequestsChanged();
return rowsAfter
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
.map((r) => r.id);
}
// Применяет решение: обновляет строку, продвигает очередь (на следующего
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
// только что завершилась целиком) и пересчитывает статус заявки.