Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
T
2026-08-19 15:04:41 +05:00

446 lines
21 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 { spfi, SPFI, SPFx } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import '@pnp/sp/site-users/web';
import '@pnp/sp/search';
import { AadHttpClient } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import {
APPROVAL_PHASE,
ManagedApprovalRow,
ManagedRequestMatrix,
ManagedRequestPage,
ManagedRequestSummary,
RequestListPartition
} from '../models';
// Список данных «<Форма> — данные» → список согласования «<Форма> — согласование»
// (конвенция имён; см. request-portal/src/webparts/requestPortal/forms/*/provisioning.json
// и forms/*/approval/provisioning.json — этот сервис читает и пишет ровно те же
// списки SharePoint, только с другого клиента).
const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/;
// Тот же поток оповещения согласующего/исполнителя, что и в request-portal
// (src/webparts/requestPortal/shared/config.ts) — один и тот же список
// согласования пишут оба веб-парта, поток сам находит по RowID согласующего,
// статус и заявку и уведомляет письмом. Авторизация — AadHttpClient с
// ресурсом Flow Service (обычный fetch → 401 из-за Entra-триггера).
const FLOW_RESOURCE = 'https://service.flow.microsoft.com/';
const FLOW_NOTIFY_URL =
'https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com:443/powerautomate/automations/direct/cu/07/workflows/b21516edf8a14287a65a39e4116dfe4f/triggers/manual/paths/invoke?api-version=1';
// Внутренние имена колонок списка согласования — 1:1 с FIELDS в
// request-portal/src/webparts/requestPortal/shared/approval/approvalList.service.ts.
// Это тот же самый список SharePoint, поэтому имена колонок должны совпадать
// буквально — здесь их не выбирают заново, а просто дублируют.
const FIELDS = {
approver: 'TansuSiteApprover',
status: 'ApprovalStatus', // статус СТРОКИ маршрута — не путать со статусом заявки (см. IRawDataRow.RequestStatus ниже)
ticket: 'Ticket',
date: 'Date',
comment: 'Comment',
order: 'StepOrder',
allowReassign: 'AllowReassignToOtherApprovers',
phase: 'Phase',
// Колонки SLA заводятся провижинингом в request-portal вместе с остальными.
slaHours: 'SLAhours',
askOverdueReason: 'AskOverdueReason'
};
function siteOrigin(siteUrl: string): string {
const match = /^https?:\/\/[^/]+/.exec(siteUrl);
return match ? match[0] : siteUrl;
}
function listAbsUrl(siteUrl: string, listId: string): string {
return `${siteOrigin(siteUrl)}${listId}`;
}
// KQL не переносит большинство спецсимволов в свободном терме — оставляем буквы
// (лат./кир.), цифры и пробелы. Та же логика, что в request-portal
// (features/myRequests/myRequests.service.ts/sanitizeForKql).
function sanitizeForKql(text: string): string {
return text.replace(/[^a-zA-Zа-яА-ЯёЁ0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function freeTextClause(query: string): string | undefined {
const q = sanitizeForKql(query);
if (!q) return undefined;
return `(${q.split(' ').map(w => `${w}*`).join(' AND ')})`;
}
function formatDateIso(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
return `${dd}.${mm}.${d.getFullYear()}`;
}
interface IRawDataRow {
Id: number;
Title?: string;
/** Статус ЗАЯВКИ (список «— данные») — не путать с FIELDS.status (статус строки маршрута). */
RequestStatus?: string;
Created?: string;
Author?: { Title?: string };
// eslint-disable-next-line @rushstack/no-new-null -- зеркалит SharePoint: колонки может не быть в старых списках/строках
RequestUniqueCode?: string | null;
}
function toSummary(r: IRawDataRow, listId: string): ManagedRequestSummary {
return {
id: r.Id,
title: r.Title || `#${r.Id}`,
status: r.RequestStatus || '',
created: r.Created || '',
authorName: r.Author?.Title || '',
uniqueCode: r.RequestUniqueCode || null,
listId
};
}
/**
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в SharePoint —
* в отличие от остального этого веб-парта (который говорит с Tnsu.ApprovalRoute.Api),
* данные заявок и строки маршрута живут только в списках SharePoint, которыми
* управляет request-portal. У бэкенда этого API нет и не будет: типы заявок
* знают только list_id/site_url (см. models/requestTypes.ts), содержимое списков
* ему не принадлежит.
*/
export class RequestManageService {
constructor(private readonly context: WebPartContext) {}
private clientFor(siteUrl: string): SPFI {
return spfi(siteUrl).using(SPFx(this.context));
}
// Title списка данных → Title списка согласования. undefined, если Title не
// оканчивается на «— данные» (список заведён не по конвенции request-portal,
// управлять маршрутом для него отсюда нельзя).
private async listTitles(sp: SPFI, listId: string): Promise<{ dataListTitle: string; approvalListTitle: string | undefined }> {
const info = (await sp.web.getList(listId).select('Title')()) as { Title: string };
const approvalListTitle = DATA_LIST_SUFFIX.test(info.Title)
? `${info.Title.replace(DATA_LIST_SUFFIX, '')} — согласование`
: undefined;
return { dataListTitle: info.Title, approvalListTitle };
}
// К какой партиции относится хит Search — по префиксу его Path. Партиции
// определяются вызывающей стороной (ListPartitionService.ListAsync), их
// список короткий (единицы), линейный поиск тут не проблема.
private partitionForPath(
siteUrl: string,
partitions: RequestListPartition[],
path: string
): RequestListPartition | undefined {
return partitions.filter(p => path.indexOf(listAbsUrl(siteUrl, p.listId)) === 0)[0];
}
/**
* Заявки по ВСЕМ партициям списка данных этого типа (архив + текущая)
* постранично, для комбобокса выбора заявки. Сначала Search по названию
* (свободный терм, пустой query — просто последние без фильтра), затем
* дочитывание полей REST'ом по найденным Id — Search не отдаёт произвольные
* поля списка надёжно (та же двухшаговая схема, что в request-portal).
*
* Заявки, набранные ростом одного типа, физически «переезжают» на новый
* список-партицию (см. ТЗ по архивации) — искать нужно сразу по всем, иначе
* заявки из архивных партиций в комбобоксе просто не найти.
*/
public async searchRequests(
partitions: RequestListPartition[],
siteUrl: string,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (partitions.length === 0) return { items: [], hasMore: false };
const sp = this.clientFor(siteUrl);
const pathClauses = partitions.map(p => `Path:"${listAbsUrl(siteUrl, p.listId)}*"`);
const pathClause = pathClauses.length > 1 ? `(${pathClauses.join(' OR ')})` : pathClauses[0];
const clauses = [pathClause];
const text = freeTextClause(query);
if (text) clauses.push(text);
const result = await sp.search({
Querytext: clauses.join(' AND '),
SelectProperties: ['ListItemID', 'Path'],
RowLimit: pageSize,
StartRow: (page - 1) * pageSize,
TrimDuplicates: false,
SortList: [{ Property: 'Created', Direction: 1 }]
});
const rawRows = (result.PrimarySearchResults ?? []) as Array<{ ListItemID?: string; Path?: string }>;
// Дедуп обязателен по паре (listId, Id), а НЕ по одному Id: у разных
// партиций независимая нумерация элементов (см. ТЗ по архивации), поэтому
// одинаковый номер в разных партициях — это две РАЗНЫЕ заявки. TrimDuplicates:
// false выше выключает штатную дедупликацию Search (она иногда схлопывает
// нужные нам заявки), и тогда индекс может отдать один и тот же элемент
// несколькими хитами (переиндексация, несколько «версий» в crawl) — та же
// причина и то же лечение, что в request-portal
// (features/myRequests/myRequests.service.ts/hitsToEntries).
const seen = new Set<string>();
const hits: Array<{ id: number; partition: RequestListPartition }> = [];
rawRows.forEach(r => {
const id = Number(r.ListItemID);
if (isNaN(id) || id <= 0 || !r.Path) return;
const partition = this.partitionForPath(siteUrl, partitions, r.Path);
if (!partition) return;
const key = `${partition.listId}|${id}`;
if (seen.has(key)) return;
seen.add(key);
hits.push({ id, partition });
});
if (hits.length === 0) return { items: [], hasMore: false };
const idsByListId = new Map<string, number[]>();
hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id]));
const rowByKey = new Map<string, IRawDataRow>();
await Promise.all(
Array.from(idsByListId.entries()).map(async ([listId, ids]) => {
const list = sp.web.getList(listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
dataRows.forEach(r => rowByKey.set(`${listId}|${r.Id}`, r));
})
);
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = hits
.map(h => {
const row = rowByKey.get(`${h.partition.listId}|${h.id}`);
return row ? toSummary(row, h.partition.listId) : undefined;
})
.filter((r): r is ManagedRequestSummary => !!r);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
/**
* Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования.
*
* listId — КОНКРЕТНАЯ партиция, где физически лежит заявка (из
* ManagedRequestSummary.listId, отданного searchRequests/предыдущей
* getMatrix) — не requestType.listId: та партиция может быть уже не той, где
* лежит эта заявка (см. ТЗ по архивации).
*/
public async getMatrix(siteUrl: string, listId: string, requestId: number): Promise<ManagedRequestMatrix> {
const sp = this.clientFor(siteUrl);
const dataList = sp.web.getList(listId);
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
dataList.items
.getById(requestId)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title', 'RequestUniqueCode')
.expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, listId)
]);
const dataListUrl = listAbsUrl(siteUrl, listId);
if (!approvalListTitle) {
return {
request: toSummary(requestRow, listId),
siteUrl,
dataListTitle,
dataListUrl,
approvalListTitle: '',
rows: []
};
}
const approvalList = sp.web.lists.getByTitle(approvalListTitle);
const rawRows = (await approvalList.items
.filter(`${FIELDS.ticket}Id eq ${requestId}`)
.select(
'Id',
'Title',
FIELDS.status,
FIELDS.date,
FIELDS.comment,
FIELDS.order,
FIELDS.allowReassign,
FIELDS.phase,
FIELDS.slaHours,
FIELDS.askOverdueReason,
`${FIELDS.approver}/Id`,
`${FIELDS.approver}/Title`,
`${FIELDS.approver}/EMail`
)
.expand(FIELDS.approver)
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
const rows: ManagedApprovalRow[] = rawRows.map((r, i) => {
const approver = r[FIELDS.approver] as { Id?: number; Title?: string; EMail?: string } | undefined;
return {
id: r.Id as number,
ticketId: requestId,
approverId: approver?.Id ?? null,
approverName: approver?.Title || '',
approverEmail: approver?.EMail || '',
position: (r.Title as string) || '',
status: (r[FIELDS.status] as string) || '',
date: formatDateIso((r[FIELDS.date] as string) || ''),
dateIso: (r[FIELDS.date] as string) || '',
comment: (r[FIELDS.comment] as string) || '',
order: (r[FIELDS.order] as number) ?? i + 1,
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval,
// ?? null, а не || null: ноль здесь — значимое значение («напоминать
// сразу»), и превращать его в «не задано» нельзя.
slaHours: (r[FIELDS.slaHours] as number | null) ?? null,
askOverdueReason: (r[FIELDS.askOverdueReason] as boolean | null) ?? null
};
});
return { request: toSummary(requestRow, listId), siteUrl, dataListTitle, dataListUrl, approvalListTitle, rows };
}
/** email → числовой Id пользователя сайта (Person-поле требует Id, не email). */
private async resolveUserId(sp: SPFI, email: string): Promise<number> {
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
const user = await sp.web.ensureUser(login);
return user.Id;
}
/** Переназначить согласующего/исполнителя строки. Вызывающая сторона обязана
* проверить isEditableRowStatus(row.status) — сервис это не перепроверяет. */
public async reassignApprover(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
rowId: number,
email: string,
position: string
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const approverId = await this.resolveUserId(sp, email);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
Title: position,
[`${FIELDS.approver}Id`]: approverId
});
}
/**
* Поправить SLA конкретной строки маршрута.
*
* null пишется в SharePoint как есть — это «в строке не задано», и тогда
* работает SLA типа заявки. Отдельный метод, а не флаг в reassignApprover:
* SLA правят независимо от того, кто на шаге, и в любом статусе строки.
*/
public async updateRowSla(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
rowId: number,
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано», см. описание выше
slaHours: number | null,
// eslint-disable-next-line @rushstack/no-new-null -- null = «не задано»
askOverdueReason: boolean | null
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).update({
[FIELDS.slaHours]: slaHours,
[FIELDS.askOverdueReason]: askOverdueReason
});
}
/** Удалить строку маршрута. */
public async removeRow(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
rowId: number
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.getById(rowId).delete();
}
/**
* Добавить новую строку в фазу заявки. order считает вызывающая сторона
* (см. ManageRequestsScreen) — он обязан быть больше order любой уже решённой
* строки той же фазы (иначе новый согласующий встанет впереди того, кто уже
* согласовал, а это правило запрещено интерфейсом).
*/
public async addRow(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
ticketId: number,
phase: string,
order: number,
status: string,
email: string,
position: string,
allowReassign: boolean
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const approverId = await this.resolveUserId(sp, email);
await sp.web.lists.getByTitle(matrix.approvalListTitle).items.add({
Title: position || email,
[`${FIELDS.approver}Id`]: approverId,
[FIELDS.status]: status,
[`${FIELDS.ticket}Id`]: ticketId,
[FIELDS.order]: order,
[FIELDS.allowReassign]: allowReassign,
[FIELDS.phase]: phase
});
}
/**
* Переставить StepOrder нескольких строк разом (перестановка шагов местами) и,
* при необходимости, статус той же строки — см. ManageRequestsScreen/
* computeActivationUpdates: после любой перестановки строка, ставшая первой
* нерешённой в очереди фазы, должна получить «На согласовании»/«На
* исполнении» вместо «Ждёт очереди» (и наоборот — та, что раньше была первой,
* а теперь нет). Оба поля пишутся одним update, чтобы не плодить лишний
* запрос на строку.
*/
public async reorderRows(
matrix: Pick<ManagedRequestMatrix, 'siteUrl' | 'approvalListTitle'>,
updates: Array<{ id: number; order?: number; status?: string }>
): Promise<void> {
const sp = this.clientFor(matrix.siteUrl);
const list = sp.web.lists.getByTitle(matrix.approvalListTitle);
await Promise.all(
updates.map(u => {
const patch: Record<string, unknown> = {};
if (u.order !== undefined) patch[FIELDS.order] = u.order;
if (u.status !== undefined) patch[FIELDS.status] = u.status;
return list.items.getById(u.id).update(patch);
})
);
}
/**
* Отправить уведомление согласующему/исполнителю конкретной строки маршрута —
* тот же поток Power Automate, что и request-portal (см. FLOW_NOTIFY_URL
* выше): поток сам находит по RowID согласующего, статус и заявку и шлёт
* письмо. В отличие от request-portal (там вызывается автоматически после
* решения), здесь — по явному клику на кнопку в таблице маршрута, поэтому
* ошибку НЕ глушим — вызывающая сторона показывает её пользователю.
*/
public async notifyRow(
requestTypeId: number,
matrix: Pick<ManagedRequestMatrix, 'request' | 'dataListTitle' | 'approvalListTitle'>,
rowId: number
): Promise<void> {
const client = await this.context.aadHttpClientFactory.getClient(FLOW_RESOURCE);
const res = await client.post(FLOW_NOTIFY_URL, AadHttpClient.configurations.v1, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
RequestTypeId: requestTypeId,
RowID: rowId,
RequestID: matrix.request.id,
ListName: matrix.dataListTitle,
ApproveListName: matrix.approvalListTitle
})
});
if (!res.ok) {
throw new Error(`Поток оповещения: ${res.status} ${res.statusText}`);
}
}
}