Files
request-portal/src/webparts/requestPortal/shared/approval/approverDecision.service.ts
T

186 lines
12 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);
emitRequestsChanged();
}
// Применяет решение: обновляет строку, продвигает очередь (на следующего
// согласующего внутри фазы либо на первого исполнителя, если фаза согласования
// только что завершилась целиком) и пересчитывает статус заявки.
// Возвращает новый статус заявки.
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,
});
}
}