Files
request-portal/src/webparts/requestPortal/shared/approval/approverDecision.service.ts
T
gorbenko.pavel 168c242a84
Tests / frontend-test (pull_request) Successful in 1m23s
Мониторинг: в логах заявок — вид, код и название заявки
«Создана заявка: Заявка на прием · TNS-… · Инженер — Проект X», «Заявка отправлена на согласование: …»,
«Заявка согласована: TNS-… · …» — заявку видно прямо в списке логов; те же данные — в полях request.*.
Отправка на согласование пишется при сохранении заявки с отправленным статусом (там известно, что за
заявка), из writeRoute лог убран. Код и название для решения дочитываются, сбой чтения решение не ломает.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:04:33 +05:00

437 lines
26 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 { log } from '@tnsu/monitoring-web';
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, IApproverToWrite } 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';
import { planQueuedStepMove, StepMoveDirection } from './queuedStepMove';
import { IStepAddRow, 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';
// Активная строка текущего пользователя: либо он сам назначен на неё
// (isDelegator=false), либо это его делегат недоступен (в отпуске и т.п.) и он,
// как делегировавший, вправе переназначить шаг ещё раз (isDelegator=true).
export interface IPendingRow extends IApprovalRow {
isDelegator: boolean;
}
// Решение → текст события в логах мониторинга.
const DECISION_MESSAGE: Record<ApproverDecision, string> = {
approve: 'Заявка согласована',
return: 'Заявка возвращена на доработку',
reject: 'Заявка отклонена',
execute: 'Заявка выполнена исполнителем',
};
// Решение → статус его строки в списке согласования.
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();
}
/**
* Поменять местами ещё не начатый шаг маршрута с соседним ждущим шагом той же
* фазы (согласование или исполнение) — право см. routeEditRights.ts, правило
* см. queuedStepMove.ts.
*
* Строки перечитываются с сервера прямо перед записью: пока на экране открыт
* маршрут, кто-то мог уже согласовать, и сосед стал активным, — тогда
* перестановка отменяется, а не ломает очередь.
*
* Участники не меняются, поэтому заявку не «трогаем» (см. touchRequest): права
* на элемент данных пересчитывать незачем, оповещать тоже некого — активный
* шаг остаётся прежним.
*/
public async moveQueuedStep(requestId: number, rowId: number, direction: StepMoveDirection): Promise<void> {
const rows = await this.approval.getForRequest(requestId);
const changes = planQueuedStepMove(rows, rowId, direction);
if (!changes) {
throw new Error('Маршрут уже изменился (шаг взят в работу или решён) — обновите заявку.');
}
await this.approval.setStepOrders(changes);
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);
}
/**
* Добавить в поданную заявку исполнителей, которых привела правка полей
* маршрута согласующим (КОГО — решает planMissingExecutors). Каждый встаёт в
* КОНЕЦ исполнения «Ждёт очереди», по тем же правилам, что addStep
* (planStepAdd), но без права редактора маршрута: людей назначает маршрут из
* админки, а не пользователь. SLA шага переносится из маршрута.
*
* Возвращает Id строк, ставших активными (обычно пусто); им нужно оповещение.
*/
public async addMissingExecutors(requestId: number, executors: IApproverToWrite[]): Promise<number[]> {
if (executors.length === 0) return [];
const rows = await this.approval.getForRequest(requestId);
const activeBefore = new Set(rows.filter((r) => isActiveStatus(r.status)).map((r) => r.id));
// По одному и с локальным дописыванием rows: иначе второй исполнитель
// получил бы тот же order, что первый, и стал бы параллельным шагом.
const planned: IStepAddRow[] = rows.slice();
for (const executor of executors) {
const approverId = await this.approval.getUserIdByEmail(executor.email);
const plan = planStepAdd(planned, APPROVAL_PHASE.execution, approverId);
if (!plan.ok) throw new Error(plan.error);
await this.approval.addApproverRow(requestId, executor, plan.order, REQUEST_STATUS.queued, APPROVAL_PHASE.execution);
planned.push({ approverId, order: plan.order, status: REQUEST_STATUS.queued, phase: APPROVAL_PHASE.execution });
}
await this.approval.activateNextQueued(requestId);
const rowsAfter = await this.approval.getForRequest(requestId);
// Новым исполнителям — права на элемент данных (см. addStep).
await this.touchRequest(requestId);
emitRequestsChanged();
return rowsAfter.filter((r) => isActiveStatus(r.status) && !activeBefore.has(r.id)).map((r) => r.id);
}
/**
* Шаг фазы «Исполнение», который ещё можно заменить или удалить: ждёт очереди
* или уже активен («На исполнении»). Выполненный шаг — решение человека, его
* не трогаем (иначе можно «переоткрыть» закрытую заявку). Бросает с текстом
* для пользователя, если шаг не подходит.
*/
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);
await this.logDecision(requestId, rowId, decision, comment, 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 async logDecision(
requestId: number,
rowId: number,
decision: ApproverDecision,
comment: string,
requestStatus: string
): Promise<void> {
let code = '';
let title = '';
try {
const item = (await this.dataList().items.getById(requestId).select('Title', 'RequestUniqueCode')()) as {
Title?: string;
RequestUniqueCode?: string;
};
code = item.RequestUniqueCode || '';
title = item.Title || '';
} catch {
// без кода и названия — лог всё равно нужен
}
const what = [code, title].filter(Boolean).join(' · ') || `№${requestId}`;
log.info(`${DECISION_MESSAGE[decision]}: ${what}`, {
'request.id': requestId,
'request.code': code,
'request.title': title,
'request.list': this.dataListRef,
'request.status': requestStatus,
'approval.decision': decision,
'approval.rowId': rowId,
'approval.comment': comment.slice(0, 500),
});
}
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 || '');
}
}