Files
request-portal/tools/sp/README.md
T
gorbenko.pavel ddce6b4f25
Tests / frontend-test (push) Successful in 1m15s
раздел «Отчёты» в боковом меню — скачивание отчёта по вакансиям (#17)
Пункт меню виден только заданному списку почт (Тришина, Городецкий,
Горбенко) — группы 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
2026-08-31 11:00:18 +00:00

151 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `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 = "<application-client-id>"
$env:SP_TENANT = "<directory-tenant-id>"
```
`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 "<KQL>" --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` печатает `<нет в ответе>` и `<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` — только
доступное вам.