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 { 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 { 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, 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>; 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 { 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, rowId: number, email: string, position: string ): Promise { 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, rowId: number ): Promise { 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, ticketId: number, phase: string, order: number, status: string, email: string, position: string, allowReassign: boolean ): Promise { 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, updates: Array<{ id: number; order?: number; status?: string }> ): Promise { const sp = this.clientFor(matrix.siteUrl); const list = sp.web.lists.getByTitle(matrix.approvalListTitle); await Promise.all( updates.map(u => { const patch: Record = {}; 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, rowId: number ): Promise { 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}`); } } }