Пункт меню виден только заданному списку почт (Тришина, Городецкий, Горбенко) — группы HR в SharePoint пока нет, добавили TODO завести проверку по ней отдельно (reportsAccess.ts). Скрыт из меню совсем, а не показан неактивным, и прямой переход по ?rpnav=reports у остальных редиректит на «Новую заявку». Раздел — форма периода (С/По) и кнопка «Скачать xlsx», дергает GET api/reports/vacancies через тот же AadHttpClient, что и остальные вызовы approval-route API. Имя файла собирается на фронте по формату бэкенда (VacancyReportWorkbook.FileName), а не читается из Content-Disposition — approval-route не объявляет заголовок в Access-Control-Expose-Headers, браузер бы его не отдал. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/17
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. Регистрируется один раз на всю команду.
- Entra admin center → App registrations → New registration
- Name:
Portal Tools CLI - Supported account types: Single tenant
- Redirect URI: Public client/native →
http://localhost
- Name:
- Authentication → Advanced settings → Allow public client flows → Yes (без этого device code не работает)
- API permissions → Add a permission → SharePoint → Delegated:
AllSites.Read— чтение списковSites.Search.All— поисковый индекс
- Grant admin consent (нужен админ тенанта)
- Скопировать 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 для этого не годится тоже, хотя соблазнительно: 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 — только
доступное вам.