Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/services/requestManageService.ts
T
gorbenko.pavel 0dedabc2ae
Tests / frontend-test (push) Successful in 47s
Tests / backend-test (push) Successful in 1m41s
переименование колонок
2026-08-10 15:14:57 +05:00

351 lines
16 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,
RequestType
} 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'
};
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 };
}
function toSummary(r: IRawDataRow): ManagedRequestSummary {
return {
id: r.Id,
title: r.Title || `#${r.Id}`,
status: r.RequestStatus || '',
created: r.Created || '',
authorName: r.Author?.Title || ''
};
}
/**
* Чтение/правка заявок и их маршрутов согласования НАПРЯМУЮ в 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 };
}
/**
* Заявки типа requestType постранично, для комбобокса выбора заявки. Сначала
* Search по названию (свободный терм, пустой query — просто последние без
* фильтра), затем дочитывание полей REST'ом по найденным Id — Search не
* отдаёт произвольные поля списка надёжно (та же двухшаговая схема, что в
* request-portal).
*/
public async searchRequests(
requestType: RequestType,
query: string,
page: number,
pageSize = 20
): Promise<ManagedRequestPage> {
if (!requestType.listId || !requestType.siteUrl) return { items: [], hasMore: false };
const sp = this.clientFor(requestType.siteUrl);
const absUrl = listAbsUrl(requestType.siteUrl, requestType.listId);
const clauses = [`Path:"${absUrl}*"`];
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 }>;
const ids = rawRows.map(r => Number(r.ListItemID)).filter(id => !isNaN(id) && id > 0);
if (ids.length === 0) return { items: [], hasMore: false };
const list = sp.web.getList(requestType.listId);
const dataRows = (await list.items
.filter(ids.map(id => `Id eq ${id}`).join(' or '))
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
.expand('Author')
.top(ids.length)()) as IRawDataRow[];
const byId = new Map(dataRows.map(r => [r.Id, r]));
// Порядок задаёт Search (SortList по дате), не порядок ответа REST.
const items = ids
.map(id => byId.get(id))
.filter((r): r is IRawDataRow => !!r)
.map(toSummary);
const totalRows = (result as unknown as { TotalRows?: number }).TotalRows ?? items.length;
return { items, hasMore: page * pageSize < totalRows };
}
/** Заявка + полный маршрут (обе фазы, по StepOrder) — для экрана редактирования. */
public async getMatrix(requestType: RequestType, requestId: number): Promise<ManagedRequestMatrix> {
if (!requestType.listId || !requestType.siteUrl) {
throw new Error('У типа заявки не указан список SharePoint.');
}
const sp = this.clientFor(requestType.siteUrl);
const dataList = sp.web.getList(requestType.listId);
const [requestRow, { dataListTitle, approvalListTitle }] = await Promise.all([
dataList.items
.getById(requestId)
.select('Id', 'Title', 'RequestStatus', 'Created', 'Author/Title')
.expand('Author')() as Promise<IRawDataRow>,
this.listTitles(sp, requestType.listId)
]);
if (!approvalListTitle) {
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, 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.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) || ''),
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
};
});
return { request: toSummary(requestRow), siteUrl: requestType.siteUrl, dataListTitle, 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
});
}
/** Удалить строку маршрута. */
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}`);
}
}
}