Files
request-portal/tools/sp
gorbenko.pavel f7979ac534 docs(tools/sp): грабли поискового индекса и прав на элементах
Проверено на тенанте 2026-08-28, при разборе «перевести отчёт по вакансиям
на Search или нет»:

1. Часть полей индекс не отдаёт ВООБЩЕ, хотя значения в списках есть
   (OpenReason/OpenReasonDetails, CandidateName/HireDate/ReplacedEmployee,
   Ticket). Рядом в тех же списках PositionOWSTEXT, RequestStatusOWSCHCS,
   SelectionRequestIdOWSNMBR и др. отдаются нормально — то есть дело не в
   типе колонки и не в задержке crawl. Добавлена таблица с тем, какие имена
   уже перепробованы, чтобы не ходить по кругу.

2. Отличить «managed property нет» от «есть, но пусто» через SelectProperties
   нельзя — на несуществующее имя молча приходит null. Отличается сортировкой:
   SortList по несуществующему свойству падает 400/500.

3. Права у заявок портала разорваны на КАЖДОМ элементе
   (HasUniqueRoleAssignments = true), поэтому под своей учёткой в списке видно
   2 элемента из 19 — не баг и не кэш браузера. App-only это не трогает.
   Отсюда же: ItemCount списка считает всё, /items — только доступное вам.

Заодно исправлено утверждение «Search под app-only обычно ничего не отдаёт» —
на сертификате CN=PnP-SharePoint-Sync отдаёт, и postquery, и кастомные
managed properties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:10:36 +05:00
..

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, на сессию):

$env:SP_APP_ID = "<application-client-id>"
$env:SP_TENANT = "<directory-tenant-id>"

SP_SITE по умолчанию https://tnsukz.sharepoint.com/sites/requests — тот же адрес, что в src/webparts/requestPortal/shared/config.ts. Задавать нужно, только если списки типов заявок живут на другом сайте.

Использование

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 "<KQL>" --select a,b --rows N тот же поисковый запрос, что делает портал
node sp.mjs get "/_api/web/..." произвольный GET, когда отдельной команды нет

Примеры:

# Свои заявки глазами индекса — ровно как их видит 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 печатает <нет в ответе> и <null> разными пометками именно поэтому: схлопывать их в пустую строку — значит потерять ответ на вопрос.

Имена 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 по несуществующему свойству падает ошибкой 400/500, по существующему — работает.

При этом рядом в тех же списках нормально отдаются PositionOWSTEXT, RequestStatusOWSCHCS, SelectionRequestIdOWSNMBR, SelectionRequestListIdOWSTEXT, SubmitDateOWSDATE, TansuSiteApproverOWSUSER — то есть это не про тип колонки и не про задержку crawl (у свежесозданных элементов рабочие поля появляются, а эти нет). Из-за этого отчёт по вакансиям в tnsu-approval-route остался на items REST по партициям, а не на Search — подробный расчёт в doc-комментарии VacancyReportService.

Права разорваны на КАЖДОМ элементе. У заявок портала HasUniqueRoleAssignments = true поштучно: доступ выдан только участникам заявки плюс общему админу. Поэтому под своей учёткой в списке видно 2 элемента из 19 — и это не баг и не кэш браузера, а модель доступа. App-only токен (сертификат) это не трогает и читает всё. Если сравниваете «сколько элементов в списке», обязательно фиксируйте, под кем смотрите: ItemCount в метаданных списка считает ВСЁ, а /items — только доступное вам.