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 { 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(); 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(); hits.forEach(h => idsByListId.set(h.partition.listId, [...(idsByListId.get(h.partition.listId) || []), h.id])); const rowByKey = new Map(); 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 { 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, 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>; 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 { 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 }); } /** * Поправить SLA конкретной строки маршрута. * * null пишется в SharePoint как есть — это «в строке не задано», и тогда * работает SLA типа заявки. Отдельный метод, а не флаг в reassignApprover: * SLA правят независимо от того, кто на шаге, и в любом статусе строки. */ public async updateRowSla( matrix: Pick, 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 { 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, 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}`); } } }