# `tools/sp` — запросы к SharePoint портала заявок Инструмент для **разбора**, а не для правки данных: задать тенанту вопрос и получить ответ текстом, который можно приложить к задаче. Появился из-за производительности: раз за разом нужно было выяснить «а что реально отдаёт поисковый индекс?», «сколько на самом деле партиций у типа заявки?» — и каждый раз это была ручная работа со снипетом в консоли браузера и копипастой результата. ## Что важно понимать про доступ **Секреты через инструмент не проходят.** Вход — device code: команда печатает код, пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у себя. Ни пароля, ни client secret в репозитории и в командах нет. **Работаем от имени пользователя, а не приложения.** Главное, что это даёт: видно ровно то же, что видит пользователь в портале, — а у заявок портала это принципиально, потому что права разорваны НА КАЖДОМ элементе (см. «Грабли» ниже). Раньше здесь было написано, что Search под app-only-токеном «обычно не отдаёт ничего». Проверено 2026-08-28 на сертификате `CN=PnP-SharePoint-Sync` (тот же, что у бэкенда `tnsu-approval-route`): **отдаёт** — и `postquery`, и кастомные managed properties. Так что app-only Search как таковой рабочий; ограничения у него другие, см. «Грабли». ## Разовая подготовка CLI for Microsoft 365 начиная с 7-й версии **не возит собственное приложение** — нужен свой Entra app. Регистрируется один раз на всю команду. 1. Entra admin center → **App registrations** → **New registration** - Name: `Portal Tools CLI` - Supported account types: **Single tenant** - Redirect URI: **Public client/native** → `http://localhost` 2. **Authentication** → Advanced settings → **Allow public client flows** → **Yes** (без этого device code не работает) 3. **API permissions** → Add a permission → **SharePoint** → **Delegated**: - `AllSites.Read` — чтение списков - `Sites.Search.All` — поисковый индекс 4. **Grant admin consent** (нужен админ тенанта) 5. Скопировать **Application (client) ID** и **Directory (tenant) ID** Дальше — переменные окружения (PowerShell, на сессию): ```powershell $env:SP_APP_ID = "" $env:SP_TENANT = "" ``` `SP_SITE` по умолчанию `https://tnsukz.sharepoint.com/sites/requests` — тот же адрес, что в `src/webparts/requestPortal/shared/config.ts`. Задавать нужно, только если списки типов заявок живут на другом сайте. ## Использование ```bash cd tools/sp npm install # один раз node sp.mjs login # покажет код — ввести его в браузере node sp.mjs status ``` | команда | зачем | | --- | --- | | `node sp.mjs props` | отдаёт ли индекс поля заявки (`Retrievable`) — от этого зависит, можно ли убрать REST-дочитывание в `loadByIds` | | `node sp.mjs lists` | все портальные списки с размерами и адресами — сколько реально партиций | | `node sp.mjs search "" --select a,b --rows N` | тот же поисковый запрос, что делает портал | | `node sp.mjs get "/_api/web/..."` | произвольный GET, когда отдельной команды нет | Примеры: ```bash # Свои заявки глазами индекса — ровно как их видит searchOwnRequests node sp.mjs search 'AuthorOWSUSER:"i:0#.f|membership|user@tnsu.kz"' \ --select ListItemID,Title,RequestStatusOWSCHCS,Created --rows 5 # Сколько элементов в каждой партиции node sp.mjs lists ``` ## Грабли, на которые уже наступали **`Queryable` ≠ `Retrievable`.** У managed property это разные флаги. То, по чему получается фильтровать, не обязано приходить в ответе. Проверка от 2026-08-17 подтвердила, что по `RequestStatusOWSCHCS` можно фильтровать, и это НЕ было доказательством, что его можно получить (проверено отдельно 2026-08-20 — можно). Команда `props` печатает `<нет в ответе>` и `` разными пометками именно поэтому: схлопывать их в пустую строку — значит потерять ответ на вопрос. **Имена managed properties нестандартные.** Суффикс — по типу исходной колонки: `OWSTEXT` (Text), `OWSCHCS` (Choice), `OWSUSER` (Person/Group). У колонок из группы колонок сайта добавляется префикс группы — поэтому «ожидаемого» `ApproverOWSUSER` в схеме нет, а есть `TansuSiteApproverOWSUSER` (разбирались 2026-08-06). Актуальные имена — в константах `MP_*` в `features/myRequests/myRequests.service.ts`. **`AuthorOWSUSER` — это не почта.** Приходит строкой `почта | ФИО`, а при claims-логине частей будет четыре. Разбирать сплитом по первому элементу нельзя. **POST `postquery`, а не GET `query`.** KQL портала перечисляет через `OR` адреса всех партиций и в query-строке упирается в предел длины URL. Форма тела взята один в один из PnPjs (`@pnp/sp/search/query.js`): `{ request: { ...query } }`, массивы плоские, `odata=nometadata`. Иначе инструмент мерил бы не то, что портал. **Часть полей индекс не отдаёт ВООБЩЕ, хотя значения в списках есть** (проверено 2026-08-28 поштучным сравнением Search и REST на одних и тех же элементах): | Список | Поле | Пробовали имена | | --- | --- | --- | | Заявка на подбор | `OpenReason`, `OpenReasonDetails` | `…OWSCHCS`, `…OWSMTXT`, `…OWSTEXT`, с префиксом `TansuSite` | | Прием на работу | `CandidateName`, `HireDate`, `ReplacedEmployee` | `…OWSTEXT`, `…OWSDATE`, с префиксом `TansuSite` | | — согласование | `Ticket` (Lookup) | `TicketOWSTEXT`, `TicketOWSLKID`, `TicketId`, `Ticket` | Отличить «managed property нет вовсе» от «есть, но у строк пусто» через `SelectProperties` НЕЛЬЗЯ — на несуществующее имя ответ молча приходит с `null`. **И `SortList` для этого не годится тоже**, хотя соблазнительно: `Sortable` — флаг, независимый от `Retrievable`, и у автосозданных `ows_*` свойств он обычно выключен. `SortList` падает 400/500 и на заведомо живом `PositionOWSTEXT`, то есть меряет не то. Рабочий способ — сверять Search с REST на одних и тех же элементах, это делает `sp-write props` (репозиторий `C:\work\git\tools`). **Корневая причина (нашлась 2026-08-28): одноимённые колонки в разных списках — это РАЗНЫЕ поля.** У каждого свой GUID, значит и своё crawled property (`ows_Position`, `ows_Position1`, …), а автосозданное managed property привязано только к одному из них. Отсюда картина, которая иначе выглядит бессмыслицей: | Managed property | «Заявка на подбор 1 — данные» | «Прием на работу 1 — данные» | | --- | --- | --- | | `PositionOWSTEXT` | отдаёт | **молчит** | | `CandidateNameOWSTEXT` | (колонки нет) | **молчит**, но свойство рабочее в других списках | | `RequestStatusOWSCHCS` | отдаёт | отдаёт | То есть проверять надо не «работает ли свойство», а «работает ли оно **для этого списка**». `sp-write props` в конце как раз и говорит, рабочее ли свойство где-то ещё на сайте: если да — колонка этого списка отдельное поле, и в Search Schema нужно домапить её crawled property к тому же managed property; если нигде — свойства нет и его надо заводить. Полностью отсутствуют в схеме (проверено по всему сайту): `OpenReason`, `OpenReasonDetails`, `HireDate`. Из-за всего этого отчёт по вакансиям в `tnsu-approval-route` остался на items REST по партициям, а не на Search — подробный расчёт в doc-комментарии `VacancyReportService`. **Права разорваны на КАЖДОМ элементе.** У заявок портала `HasUniqueRoleAssignments = true` поштучно: доступ выдан только участникам заявки плюс общему админу. Поэтому под своей учёткой в списке видно 2 элемента из 19 — и это не баг и не кэш браузера, а модель доступа. App-only токен (сертификат) это не трогает и читает всё. Если сравниваете «сколько элементов в списке», обязательно фиксируйте, под кем смотрите: `ItemCount` в метаданных списка считает ВСЁ, а `/items` — только доступное вам.