import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config'; import { ApprovalStatus, ICurrentListPartition, IApiResponse, IRequestType, IRequestTypeListSeries, IResolvedRoute, IResolveFieldValue } from './models/IApprovalRouteApi'; // Все обращения к внешнему approval-route API — только здесь. // Клиент авторизуется через AadHttpClient (SSO по текущему пользователю). async function client(context: WebPartContext): Promise { return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); } // Ретрай-политика на сетевые сбои — тот же принцип и те же по порядку величины // значения, что и у RETRY в shared/pnp/pnpjs.ts (там для PnPjs/SharePoint). const NETWORK_RETRY_ATTEMPTS = 2; const NETWORK_RETRY_INTERVAL_MS = 1000; /** * Мягкий ретрай на СЕТЕВЫЕ сбои (fetch не долетел вовсе — TypeError «Failed to * fetch»/«NetworkError», не HTTP-статус). Тот же принцип, что и * BrowserFetchWithRetry у PnPjs-клиента (shared/pnp/pnpjs.ts) — там ретрай уже * есть, а этот внешний API его был лишён, из-за чего случайная заминка сети или * холодный старт бэкенда сразу превращались в необратимое «Failed to fetch» в * UI матрицы. HTTP-ошибки (404/401/500 — throw Error ниже по коду) НЕ ретраим: * повтор того же запроса их не лечит, только тянет время до сообщения об ошибке. */ async function withNetworkRetry(fn: () => Promise): Promise { for (let attempt = 0; ; attempt += 1) { try { return await fn(); } catch (e) { if (attempt >= NETWORK_RETRY_ATTEMPTS || !(e instanceof TypeError)) throw e; await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_INTERVAL_MS * (attempt + 1))); } } } // GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы // сопоставить форму с RequestTypeId, если он неизвестен заранее. export async function getRequestTypes(context: WebPartContext): Promise { return withNetworkRetry(async () => { const c = await client(context); const response: HttpClientResponse = await c.get( `${getApprovalApiBaseUrl()}/api/request-types`, AadHttpClient.configurations.v1 ); if (!response.ok) { throw new Error(`request-types: ${response.status} ${response.statusText}`); } const body: IApiResponse = await response.json(); return body.data ?? []; }); } /** * GET /api/request-types/lists — серии партиций сразу по ВСЕМ типам заявок. * * Именно bulk-эндпоинт, а не по одному на тип: партиции нужны и для записи (куда * писать), и для поиска (по каким спискам искать), причём поиск идёт сразу по * всем типам — запрашивать серию отдельно на каждый тип означало бы N запросов * на каждое открытие «Моих заявок». * * Типы без настроенного списка SharePoint в ответ не попадают. */ export async function getListPartitions(context: WebPartContext): Promise { return withNetworkRetry(async () => { const c = await client(context); const response: HttpClientResponse = await c.get( `${getApprovalApiBaseUrl()}/api/request-types/lists`, AadHttpClient.configurations.v1 ); if (!response.ok) { throw new Error(`request-types/lists: ${response.status} ${response.statusText}`); } const body: IApiResponse = await response.json(); return body.data ?? []; }); } /** * GET /api/request-types/{id}/lists/current — партиция, в которую нужно писать * НОВУЮ заявку этого типа. Вызывать перед КАЖДЫМ созданием, не кешировать: этот * запрос заодно проверяет порог элементов на бэкенде и ставит в очередь * создание следующей партиции при достижении — без него архивация никогда не * запустится, сколько бы заявок ни создали (bulk-эндпоинт lists, который * портал и так грузит при onInit, только читает реестр и порог не проверяет). */ export async function getCurrentListPartition( context: WebPartContext, requestTypeId: number ): Promise { return withNetworkRetry(async () => { const c = await client(context); const response: HttpClientResponse = await c.get( `${getApprovalApiBaseUrl()}/api/request-types/${requestTypeId}/lists/current`, AadHttpClient.configurations.v1 ); if (!response.ok) { throw new Error(`request-types/${requestTypeId}/lists/current: ${response.status} ${response.statusText}`); } const body: IApiResponse = await response.json(); return body.data; }); } // Кеш на уровне МОДУЛЯ (не useRef/useState) — список типов заявок админ меняет // редко, а без кеша каждая форма и каждый переход в режим согласующего дёргали // бы /api/request-types заново. Живёт до перезагрузки страницы — тот же принцип, // что и у features/myRequests/pageCache.ts. Ошибку НЕ кешируем: если запрос // упал (сеть, 401 на холодном старте), следующий вызов должен попробовать заново, // а не молча повторять один и тот же провал до конца сессии. let requestTypesPromise: Promise | undefined; export async function getRequestTypesCached(context: WebPartContext): Promise { if (!requestTypesPromise) { requestTypesPromise = getRequestTypes(context).catch((e) => { requestTypesPromise = undefined; throw e; }); } return requestTypesPromise; } /** * Числовой RequestTypeId по pageUrl (rptype) формы. Id типа заявки выдаётся * сервером при регистрации типа в админке approval-route и НЕПРЕДСКАЗУЕМ * заранее (см. README форм — «в старой pto-tracker было 1, при заведении типа * в админке получил новый id 11»), а pageUrl задаём мы сами в data/requests.ts * и он стабилен. Поэтому формы резолвят числовой id отсюда (через * useRequestTypeId), а не хардкодят его. */ export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl: string): Promise { const types = await getRequestTypesCached(context); const found = types.filter((t) => t.pageUrl === pageUrl)[0]; if (!found) { throw new Error( `Тип заявки с pageUrl «${pageUrl}» не найден в approval-route API — зарегистрируйте его в админке маршрутов (Типы заявок).` ); } return found.id; } // GET /api/request-type-access/{requestTypeId}/emails — email'ы, кому в // админке approval-route выдан доступ видеть ВСЕ заявки этого типа (не только // свои). Тот же эндпоинт дёргает и Power Automate — здесь просто ещё один // потребитель. export async function getRequestTypeAccessEmails( context: WebPartContext, requestTypeId: number ): Promise { return withNetworkRetry(async () => { const c = await client(context); const response: HttpClientResponse = await c.get( `${getApprovalApiBaseUrl()}/api/request-type-access/${requestTypeId}/emails`, AadHttpClient.configurations.v1 ); if (!response.ok) { throw new Error(`request-type-access/${requestTypeId}/emails: ${response.status} ${response.statusText}`); } const body: IApiResponse = await response.json(); return body.data ?? []; }); } // POST /api/template-routes/resolve — планируемый маршрут для типа заявки и // пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position) // по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута: // 'Approval' — согласующие (кто одобряет), // 'Execution' — исполнители (кто выполняет после согласования). // Один и тот же маршрут, разные наборы шагов. // // fieldValues — условие видимости шагов (см. IResolveFieldValue): для форм с // шагами, зависящими от значения колонки списка (напр. WorkType), передать // текущее значение поля здесь. Формы без условных шагов его не передают — // бэкенд получает пустой массив, как раньше. export async function resolveRoute( context: WebPartContext, requestTypeId: number, userEmail: string, status: ApprovalStatus = 'Approval', // eslint-disable-next-line @rushstack/no-new-null -- null отправляется в теле запроса как «не задано», undefined в JSON.stringify пропал бы вовсе departmentId: string | null = null, fieldValues: IResolveFieldValue[] = [], // eslint-disable-next-line @rushstack/no-new-null -- см. departmentId projectId: string | null = null ): Promise { return withNetworkRetry(async () => { const c = await client(context); const url = `${getApprovalApiBaseUrl()}/api/template-routes/resolve`; // Эндпоинт сменился с GET (query) на POST (JSON-тело). // projectId (uuid проекта) — для форм, где согласующий зависит от проекта // (напр. «руководитель проекта-источника» в СЗ на перемещение ТМЦ): бэкенд по // нему подставляет исполнителя роли. Формы без привязки к проекту шлют null. const response: HttpClientResponse = await c.post(url, AadHttpClient.configurations.v1, { headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ requestTypeId, userEmail, projectId: projectId || null, status, fieldValues, departmentId: departmentId || null }) }); if (!response.ok) { throw new Error(`resolve: ${response.status} ${response.statusText}`); } const body: IApiResponse = await response.json(); return body.data; }); }