186 lines
12 KiB
TypeScript
186 lines
12 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|