168c242a84
Tests / frontend-test (pull_request) Successful in 1m23s
«Создана заявка: Заявка на прием · TNS-… · Инженер — Проект X», «Заявка отправлена на согласование: …», «Заявка согласована: TNS-… · …» — заявку видно прямо в списке логов; те же данные — в полях request.*. Отправка на согласование пишется при сохранении заявки с отправленным статусом (там известно, что за заявка), из writeRoute лог убран. Код и название для решения дочитываются, сбой чтения решение не ломает. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
437 lines
26 KiB
TypeScript
437 lines
26 KiB
TypeScript
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 || '');
|
||
}
|
||
}
|