Files
request-portal/src/webparts/requestPortal/shared/approval/approverDecision.service.ts
T
Тимур Городецкий 2e0c942fe5
Tests / frontend-test (push) Successful in 2m3s
fix: права на заявку новому участнику после делегирования и замены исполнителя
Права на заявку новым участникам маршрута выдаёт бэкенд по вебхуку на
изменение элемента списка данных. Делегирование и замена/удаление
исполнителя меняли только список согласования — вебхук не приходил, и
новый человек видел «Элемент не найден». Теперь после этих действий
заявка перезаписывается с тем же статусом: значение не меняется, но
появляется новая версия элемента и срабатывает вебхук.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 10:21:56 +05:00

289 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getSP } from '../pnp/pnpjs';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS, APPROVAL_PHASE } from './models/statuses';
import { IApprovalRow } from './models/IApprovalRow';
import { ISiteUser } from './models/ISiteUser';
import { emitRequestsChanged } from '../requestEvents';
// approve/return/reject — согласующий; execute — исполнитель отметил «Выполнено».
export type ApproverDecision = 'approve' | 'return' | 'reject' | 'execute';
// Активная строка текущего пользователя: либо он сам назначен на неё
// (isDelegator=false), либо это его делегат недоступен (в отпуске и т.п.) и он,
// как делегировавший, вправе переназначить шаг ещё раз (isDelegator=true).
export interface IPendingRow extends IApprovalRow {
isDelegator: boolean;
}
// Решение → статус его строки в списке согласования.
const ROW_STATUS: Record<ApproverDecision, string> = {
approve: REQUEST_STATUS.approved,
return: REQUEST_STATUS.returned,
reject: REQUEST_STATUS.rejected,
execute: REQUEST_STATUS.executed,
};
// Действия согласующего: определение его строки, применение решения,
// делегирование и пересчёт статуса заявки в контентном списке.
//
// ВНИМАНИЕ: используется в режиме согласующего, который открывается по конкретной
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
// готов, но UI-режим согласующего ещё не подключён (см. план).
export class ApproverService {
/**
* dataListRef — либо Title списка данных (текущая партиция, как раньше), либо
* его серверный относительный url (конкретная, в т.ч. архивная партиция —
* см. shared/listTitles.ts). Различаем по ведущему '/': Title списка в
* SharePoint с него не начинается, url — всегда начинается. Так конструктору
* не нужен третий параметр «что именно передали» — вызывающая сторона просто
* передаёт то, что у неё есть (см. submit.ts каждой формы).
*/
constructor(
private readonly approval: ApprovalListService,
private readonly dataListRef: string
) {}
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
// Очередь уже проставлена на запись (см. createSteps/advanceQueue) — активна
// ровно одна строка со статусом «На согласовании» на фазу, искать «первую по
// порядку» не нужно.
//
// Если пользователь сам не назначен на активную строку, но именно он когда-то
// делегировал её текущему держателю (delegatedById) — строка тоже считается
// «его», но с isDelegator=true: DecisionPanel в этом случае даёт только
// переназначить (не решать за делегата), см. её использование там.
public async getPendingRowForCurrentUser(requestId: number): Promise<IPendingRow | undefined> {
const rows = await this.approval.getForRequest(requestId);
const userId = await this.approval.getCurrentUserId();
// Активные строки текущего шага: согласующий («На согласовании») или, когда
// согласование пройдено, исполнитель («На исполнении»). При параллельном шаге
// (одинаковый StepOrder) активных строк может быть несколько сразу — ищем
// среди ВСЕХ активных, а не только среди первой по порядку.
const active = rows.filter(
(r) => r.status === REQUEST_STATUS.onApproval || r.status === REQUEST_STATUS.onExecution
);
const own = active.filter((r) => r.approverId === userId)[0];
if (own) return { ...own, isDelegator: false };
const delegated = active.filter((r) => r.delegatedById === userId)[0];
return delegated ? { ...delegated, isDelegator: true } : undefined;
}
// Делегирование: согласующий добавляет одного или нескольких делегантов ПЕРЕД
// собой. Order каждому — из равных долей промежутка между предыдущей строкой и
// своей, В ПОРЯДКЕ ВЫБОРА: выбранный первым согласует первым. Делеганты сами не
// делегируют (allowReassign всегда false) — зато делегировавший (текущий
// пользователь) сохраняет право переназначить строку ещё раз, если кто-то из них
// окажется недоступен (см. getPendingRowForCurrentUser/isDelegator).
//
// Дробный Order — осознанно: он позволяет вклиниться между уже существующими
// шагами, не переписывая их (в SharePoint StepOrder — Number, дробные значения
// хранит). Делители растут с числом делегантов, но при реальных количествах
// (единицы) точности double хватает с колоссальным запасом.
public async delegate(
requestId: number,
currentRowId: number,
users: ISiteUser[]
): Promise<void> {
if (users.length === 0) return;
const rows = await this.approval.getForRequest(requestId);
const current = rows.filter((r) => r.id === currentRowId)[0];
if (!current) return;
// Предыдущий шаг — с меньшим Order. У параллельных шагов Order общий, поэтому
// берём именно последний меньший, а не «строку выше по списку».
const before = rows.filter((r) => r.order < current.order);
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
const step = (current.order - prevOrder) / (users.length + 1);
const delegatedById = await this.approval.getCurrentUserId();
// Последовательно, а не Promise.all: добавление строки резолвит пользователя и
// пишет в список — параллельные пачки упираются в троттлинг SharePoint, а
// выигрыш во времени на единицах строк незаметен.
for (let i = 0; i < users.length; i += 1) {
const user = users[i];
const position = await this.approval.getJobTitle(user.loginName).catch(() => '');
// Делегат наследует фазу той строки, вместо которой согласует (согласование/исполнение).
await this.approval.addApproverRow(
requestId,
{ email: user.email, position, allowReassign: false },
prevOrder + step * (i + 1),
REQUEST_STATUS.queued,
current.phase,
delegatedById
);
}
// Все строки — и делеганты, и прежний согласующий — уходят в «Ждёт очереди»,
// activateNextQueued затем активирует первого делеганта (у него меньший order).
await this.approval.setStatus(current.id, REQUEST_STATUS.queued);
await this.approval.activateNextQueued(requestId);
await this.touchRequest(requestId);
emitRequestsChanged();
}
/**
* Удалить ЕЩЁ НЕ РЕШЁННЫЙ шаг маршрута (статус «Ждёт очереди») — отдельная от
* обычного решения возможность (см. routeEditRights.ts): не «я согласовал/
* отклонил свой шаг», а «этого шага в маршруте вообще быть не должно».
* Активный («На согласовании») или уже решённый шаг не трогаем — это уже
* история решений, удалять её нельзя даже тем, у кого есть это право.
*/
public async deleteQueuedStep(requestId: number, rowId: number): Promise<void> {
const rows = await this.approval.getForRequest(requestId);
const row = rows.filter((r) => r.id === rowId)[0];
if (!row || row.status !== REQUEST_STATUS.queued) {
throw new Error('Шаг уже в работе или решён — удалить нельзя.');
}
await this.approval.deleteRow(rowId);
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 });
await this.touchRequest(requestId);
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);
await this.touchRequest(requestId);
emitRequestsChanged();
return rowsAfter
.filter((r) => r.status === REQUEST_STATUS.onExecution && !activeBefore.has(r.id))
.map((r) => r.id);
}
// Применяет решение: обновляет строку, продвигает очередь (на следующего
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
// только что завершилась целиком) и пересчитывает статус заявки.
// Возвращает новый статус заявки.
public async decide(
requestId: number,
rowId: number,
decision: ApproverDecision,
comment: string
): Promise<string> {
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
// Положительное решение двигает очередь — активируем следующий ждущий шаг
// (в т.ч. переход из согласования в исполнение). При возврате/отклонении
// очередь не двигаем: заявка уходит на доработку.
if (decision === 'approve' || decision === 'execute') {
await this.approval.activateNextQueued(requestId);
}
const rows = await this.approval.getForRequest(requestId);
const requestStatus = this.computeRequestStatus(rows);
await this.updateRequestStatus(requestId, requestStatus);
emitRequestsChanged();
return requestStatus;
}
// Статус заявки из строк маршрута с учётом фазы:
// 1) фаза согласования: отклонение/возврат → соответствующий статус;
// не все согласовали → «На согласовании».
// 2) согласование завершено: нет исполнителей → «Согласовано» (финал);
// исполнители есть и не все выполнили → «Выполнение»;
// все выполнили → «Закрыта».
// Строка исполнителя после действия «Выполнено» (DecisionPanel) получает
// статус «Выполнено»; фазы различаются колонкой Phase.
private computeRequestStatus(rows: IApprovalRow[]): string {
const execRows = rows.filter((r) => r.phase === APPROVAL_PHASE.execution);
const approvalRows = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution);
if (approvalRows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
if (approvalRows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
const approvalDone =
approvalRows.length === 0 || approvalRows.every((r) => r.status === REQUEST_STATUS.approved);
if (!approvalDone) return REQUEST_STATUS.onApproval;
if (execRows.length === 0) return REQUEST_STATUS.approved;
if (execRows.every((r) => r.status === REQUEST_STATUS.executed)) return REQUEST_STATUS.closed;
return REQUEST_STATUS.execution;
}
private dataList(): IList {
return this.dataListRef.startsWith('/')
? getSP().web.getList(this.dataListRef)
: getSP().web.lists.getByTitle(this.dataListRef);
}
private async updateRequestStatus(id: number, status: string): Promise<void> {
await this.dataList().items.getById(id).update({
RequestStatus: status,
});
}
// Права на заявку новым участникам маршрута выдаёт бэкенд по вебхуку на
// изменение элемента СПИСКА ДАННЫХ. Делегирование и замена/удаление
// исполнителя меняют только список согласования — без этой записи вебхук не
// приходит, и новый человек видит «Элемент не найден». Пишем текущий статус
// как есть: значение не меняется, но у элемента появляется новая версия.
private async touchRequest(id: number): Promise<void> {
const item = (await this.dataList().items.getById(id).select('RequestStatus')()) as {
RequestStatus?: string;
};
await this.updateRequestStatus(id, item.RequestStatus || '');
}
}