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 ROW_STATUS: Record = { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await this.dataList().items.getById(id).update({ RequestStatus: status, }); } // Права на заявку новым участникам маршрута выдаёт бэкенд по вебхуку на // изменение элемента СПИСКА ДАННЫХ. Делегирование и замена/удаление // исполнителя меняют только список согласования — без этой записи вебхук не // приходит, и новый человек видит «Элемент не найден». Пишем текущий статус // как есть: значение не меняется, но у элемента появляется новая версия. private async touchRequest(id: number): Promise { const item = (await this.dataList().items.getById(id).select('RequestStatus')()) as { RequestStatus?: string; }; await this.updateRequestStatus(id, item.RequestStatus || ''); } }