Merge branch 'main' of http://git.tnsu.kz/timur.gorodetskiy/request-portal
Tests / frontend-test (push) Successful in 4m16s

This commit is contained in:
Тимур Городецкий
2026-08-17 12:06:26 +05:00
47 changed files with 1962 additions and 359 deletions
@@ -5,6 +5,8 @@ import { IList } from '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/profiles';
import '@pnp/sp/batching';
import { getImpersonatedEmail } from '../impersonation';
import { IApprovalRow } from './models/IApprovalRow';
import { IMatrixRow } from './models/IMatrixRow';
import { ISiteUser } from './models/ISiteUser';
@@ -105,11 +107,31 @@ export class ApprovalListService {
);
}
for (const { a, order } of withOrder.filter((x) => !!x.a.email)) {
const approverId = await this.resolveUserId(a.email);
await this.list().items.add({
const toWrite = withOrder.filter((x) => !!x.a.email);
if (toWrite.length === 0) return;
// Резолв Id согласующих — независимые запросы (ensureUser по email),
// ждать их по очереди незачем. На длинном маршруте (десяток+ шагов) это
// было основным источником задержки наравне с самой записью строк ниже.
const approverIds = await Promise.all(toWrite.map(({ a }) => this.resolveUserId(a.email)));
// Сами строки — ОДНИМ HTTP-запросом через batch API SharePoint, а не по
// POST на строку: add() на batched-инстансе не уходит в сеть сразу, а
// копится и отправляется одним вызовом по execute().
//
// ВАЖНО: batchedList (сам список) переиспользуем, а .items достаём ЗАНОВО
// на каждой итерации — PnPjs один и тот же Items-инстанс во второй add()
// внутри одного батча не даёт («This instance is already part of a batch»,
// см. docs/concepts/batching#reuse). .getById(id) ниже в deleteForRequest
// этой проблемы не имеет — он и так создаёт новый инстанс на каждый вызов.
const [batchedSP, execute] = getSP().batched();
const batchedList = batchedSP.web.lists.getByTitle(this.listTitle);
toWrite.forEach(({ a, order }, i) => {
// void: промис от add() на batched-инстансе нарочно не ждём поштучно —
// он разрешится вместе со всеми остальными после execute() ниже.
void batchedList.items.add({
Title: a.position || a.email,
[`${FIELDS.approver}Id`]: approverId,
[`${FIELDS.approver}Id`]: approverIds[i],
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: requestId,
[FIELDS.order]: order,
@@ -119,7 +141,8 @@ export class ApprovalListService {
[FIELDS.slaHours]: a.slaHours ?? null,
[FIELDS.askOverdueReason]: a.askOverdueReason ?? null,
});
}
});
await execute();
}
// Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди»
@@ -205,6 +228,7 @@ export class ApprovalListService {
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.order,
FIELDS.allowReassign,
@@ -224,6 +248,7 @@ export class ApprovalListService {
approverId: approver?.Id ?? 0,
order: (r[FIELDS.order] as number) ?? 0,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
position: (r.Title as string) || '',
// Пустая фаза (старые строки без колонки) трактуется как «Согласование».
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
delegatedById: delegatedBy?.Id ?? 0,
@@ -306,7 +331,15 @@ export class ApprovalListService {
// сотрудника отдаёт именно e-mail (см. pickedLogin), а getPropertiesFor ждёт
// account name — приводим e-mail к клеймовому виду так же, как ensure().
public async getJobTitle(loginName: string): Promise<string> {
if (!loginName) return '';
return (await this.getProfileFields(loginName)).jobTitle;
}
// Должность + подразделение из профиля SharePoint (SPS-JobTitle/SPS-Department)
// ОДНИМ запросом — используется там, где нужны оба поля (напр. предзаполнение
// «Обходного листа»), чтобы не дёргать getPropertiesFor дважды подряд.
// '' по каждому полю, если недоступно/профиль не синхронизирован.
public async getProfileFields(loginName: string): Promise<{ jobTitle: string; department: string }> {
if (!loginName) return { jobTitle: '', department: '' };
const account = loginName.indexOf('|') >= 0 ? loginName : `i:0#.f|membership|${loginName}`;
try {
const props = (await getSP().profiles.getPropertiesFor(account)) as {
@@ -318,9 +351,9 @@ export class ApprovalListService {
: ((raw as { results?: Array<{ Key: string; Value: string }> })?.results ?? []);
const map: Record<string, string> = {};
for (const p of list) map[p.Key] = p.Value;
return map['SPS-JobTitle'] || map.Title || '';
return { jobTitle: map['SPS-JobTitle'] || map.Title || '', department: map['SPS-Department'] || '' };
} catch {
return '';
return { jobTitle: '', department: '' };
}
}
@@ -338,13 +371,28 @@ export class ApprovalListService {
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
public async deleteForRequest(requestId: number): Promise<void> {
const rows = await this.getForRequest(requestId);
for (const row of rows) {
await this.list().items.getById(row.id).delete();
}
if (rows.length === 0) return;
// writeRoute вызывает это перед перезаписью (пересохранение черновика,
// повторная отправка) — тот же per-row POST/DELETE в цикле, что и
// раньше в createSteps, и то же решение: один batch вместо N запросов.
const [batchedSP, execute] = getSP().batched();
const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items;
rows.forEach((row) => {
// void — по той же причине, что и в createSteps выше.
void batchedItems.getById(row.id).delete();
});
await execute();
}
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
// При отладочной подмене личности (см. shared/impersonation.ts) отдаём Id
// подменённого сотрудника: иначе «моя очередь согласования» и делегирование
// считались бы по реальной учётке, и подмена не позволяла бы проверить шаг
// чужого согласующего — ради чего она и заводилась.
public async getCurrentUserId(): Promise<number> {
const impersonated = getImpersonatedEmail();
if (impersonated) return this.resolveUserId(impersonated);
const user = await getSP().web.currentUser.select('Id')();
return user.Id;
}
@@ -365,6 +413,24 @@ export class ApprovalListService {
}
}
// Id пользователя сайта → email. Обратное к resolveUserId: строки маршрута
// хранят согласующего числовым Id (Person-поле), а адрес нужен там, где
// решение принимается именно по почте — сейчас это заглушка рассылки
// (см. notify.service.ts).
//
// '' при любой неудаче (пользователя нет, поле пустое, запрос упал) —
// вызывающая сторона трактует это как «адрес неизвестен» и ведёт себя
// консервативно, а не считает, что проверка пройдена.
public async resolveUserEmail(userId: number): Promise<string> {
if (!userId) return '';
try {
const user = (await getSP().web.siteUsers.getById(userId).select('Email')()) as { Email?: string };
return user.Email || '';
} catch {
return '';
}
}
private async ensure(email: string): Promise<{ Id: number; Title: string }> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
return getSP().web.ensureUser(login);
@@ -24,6 +24,43 @@ export async function getRequestTypes(context: WebPartContext): Promise<IRequest
return body.data ?? [];
}
// Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет
// редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали
// бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип,
// что и у features/myRequests/pageCache.ts. Ошибку НЕ кешируем: если запрос
// упал (сеть, 401 на холодном старте), следующий вызов должен попробовать заново,
// а не молча повторять один и тот же провал до конца сессии.
let requestTypesPromise: Promise<IRequestType[]> | undefined;
async function getRequestTypesCached(context: WebPartContext): Promise<IRequestType[]> {
if (!requestTypesPromise) {
requestTypesPromise = getRequestTypes(context).catch((e) => {
requestTypesPromise = undefined;
throw e;
});
}
return requestTypesPromise;
}
/**
* Числовой RequestTypeId по pageUrl (rptype) формы. Id типа заявки выдаётся
* сервером при регистрации типа в админке approval-route и НЕПРЕДСКАЗУЕМ
* заранее (см. README форм — «в старой pto-tracker было 1, при заведении типа
* в админке получил новый id 11»), а pageUrl задаём мы сами в data/requests.ts
* и он стабилен. Поэтому формы резолвят числовой id отсюда (через
* useRequestTypeId), а не хардкодят его.
*/
export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl: string): Promise<number> {
const types = await getRequestTypesCached(context);
const found = types.filter((t) => t.pageUrl === pageUrl)[0];
if (!found) {
throw new Error(
`Тип заявки с pageUrl «${pageUrl}» не найден в approval-route API — зарегистрируйте его в админке маршрутов (Типы заявок).`
);
}
return found.id;
}
// POST /api/template-routes/resolve — планируемый маршрут для типа заявки и
// пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position)
// по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута:
@@ -19,7 +19,9 @@ export type {
IResolveFieldValue,
} from './models/IApprovalRouteApi';
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
export { getRequestTypes, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service';
export { useRequestTypeId } from './useRequestTypeId';
export type { IRequestTypeIdState } from './useRequestTypeId';
export { ApprovalListService } from './approvalList.service';
export type { IApproverToWrite } from './approvalList.service';
export { ApproverService } from './approverDecision.service';
@@ -5,6 +5,10 @@ export interface IApprovalRow {
approverId: number; // Id пользователя сайта в поле «Согласующий»
order: number; // колонка Order (очередь согласования)
allowReassign: boolean; // AllowReassignToOtherApprovers
/** Должность/роль шага — колонка Title строки (см. createSteps: `a.position || a.email`).
* Нужна формам, где ПРАВА согласующего зависят от его должности (напр. «Обходной
* лист»: КДП правит СКУД и сумму обучения, подбор — парковочную карту). */
position: string;
phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE)
/** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка не делегирована
* (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз,
@@ -4,6 +4,21 @@ import { FLOW_RESOURCE, FLOW_NOTIFY_URL } from '../config';
import { ApprovalListService } from './approvalList.service';
import { REQUEST_STATUS } from './models/statuses';
/**
* ВРЕМЕННАЯ ЗАГЛУШКА на период обкатки рассылки: уведомление отправляется,
* только если активный шаг назначен на этот адрес. Для всех остальных
* согласующих поток не вызывается вообще — письма не будет.
*
* Ограничение стоит ЗДЕСЬ, на стороне портала, а не в потоке Power Automate,
* потому что адресата поток определяет САМ по RowID — тело запроса на выбор
* получателя не влияет. Значит, единственный способ не отправить письмо, не
* трогая поток, — не дёргать его. Переадресовать письмо на тестовый ящик
* отсюда нельзя по той же причине: поток такого параметра не принимает.
*
* Пустая строка — заглушка снята, уведомления идут всем как обычно.
*/
const NOTIFY_ONLY_TO = 'gorbenko.pavel@tnsu.kz';
// Данные формы для тела запроса к потоку (постоянны для формы).
export interface INotifyTarget {
requestTypeId: number; // RequestTypeId (id типа заявки во внешнем API маршрутов)
@@ -27,6 +42,24 @@ async function postNotify(context: WebPartContext, body: Record<string, unknown>
}
}
// Разрешена ли отправка на согласующего активной строки (см. NOTIFY_ONLY_TO).
//
// Неизвестный адрес (пользователь не найден, поле пустое, запрос упал) —
// НЕ отправляем: пока заглушка включена, сомнение трактуется в пользу
// «письмо живому человеку лучше не слать», а не наоборот.
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
if (!NOTIFY_ONLY_TO) return true;
const email = await approval.resolveUserEmail(approverId);
if (email.toLowerCase() === NOTIFY_ONLY_TO.toLowerCase()) return true;
console.warn(
`[Оповещение] Включена заглушка рассылки: письмо для «${email || 'адрес неизвестен'}» не отправлено ` +
`(разрешён только ${NOTIFY_ONLY_TO})`
);
return false;
}
// Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его.
// Вызывать после отправки заявки и после каждого положительного решения. Поток по
// переданным полям сам находит согласующего/исполнителя и шлёт уведомление.
@@ -45,6 +78,9 @@ export async function notifyActiveStep(
if (!active) {
return;
}
if (!(await isNotifyAllowed(approval, active.approverId))) {
return;
}
await postNotify(context, {
RequestTypeId: target.requestTypeId,
RowID: active.id,
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import { getRequestTypeIdByPageUrl } from './approvalRoute.service';
export interface IRequestTypeIdState {
/** undefined, пока не резолвился (или если резолв упал — см. error). */
id: number | undefined;
loading: boolean;
error: string | undefined;
}
/**
* Резолвит числовой RequestTypeId по pageUrl формы (см. getRequestTypeIdByPageUrl).
* Формы больше не хардкодят число — используют этот хук с уже известным pageUrl:
* в форме подачи это requestType.id (проп IRequestFormProps), в режиме
* согласующего — formKey (проп IApprovalViewProps), см. ApproveFeature.
*/
export function useRequestTypeId(context: WebPartContext, pageUrl: string): IRequestTypeIdState {
const [id, setId] = useState<number | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | undefined>(undefined);
useEffect(() => {
let alive = true;
setLoading(true);
setError(undefined);
getRequestTypeIdByPageUrl(context, pageUrl)
.then((v) => {
if (alive) {
setId(v);
setLoading(false);
}
})
.catch((e: Error) => {
if (alive) {
setError(e.message);
setLoading(false);
}
});
return () => {
alive = false;
};
}, [context, pageUrl]);
return { id, loading, error };
}
@@ -0,0 +1,39 @@
import { WebPartContext } from '@microsoft/sp-webpart-base';
/**
* ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им
* пользуется указанный сотрудник. Нужна, чтобы проверять маршруты и права
* согласующего (например, «карту СКУД правит начальник КДП» в обходном листе),
* не выпрашивая учётку у каждого участника маршрута.
*
* Значение задаётся в свойствах веб-части (property pane, см.
* RequestPortalWebPart.getPropertyPaneConfiguration) и живёт на уровне модуля —
* иначе его пришлось бы протаскивать пропсами через всё дерево до каждой формы.
*
* ЧТО ЭТО МЕНЯЕТ: только то, ЧЕЙ email портал отправляет как инициатора при
* планировании маршрута и по какому пользователю фильтрует «свои» заявки и
* очередь согласования.
*
* ЧЕГО ЭТО НЕ МЕНЯЕТ: прав доступа. Запись в SharePoint по-прежнему идёт под
* реальной учёткой (Author/Editor в списках — настоящий пользователь), токен к
* approval-route API тоже настоящий. То есть подмена не даёт увидеть то, к чему
* нет доступа, но ПОЗВОЛЯЕТ принять решение за чужой шаг согласования — поэтому
* на бою поле должно быть пустым (см. предупреждающую полосу в RequestPortal).
*/
let impersonatedEmail = '';
/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */
export function setImpersonatedEmail(email: string | undefined): void {
impersonatedEmail = (email || '').trim();
}
/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */
export function getImpersonatedEmail(): string {
return impersonatedEmail;
}
/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */
export function effectiveUserEmail(context: WebPartContext): string {
return impersonatedEmail || context.pageContext.user.email || '';
}