351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
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}`);
|
||
}
|
||
}
|
||
}
|