ddce6b4f25
Tests / frontend-test (push) Successful in 1m15s
Пункт меню виден только заданному списку почт (Тришина, Городецкий, Горбенко) — группы 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
151 lines
11 KiB
Markdown
151 lines
11 KiB
Markdown
# `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` — только
|
||
доступное вам.
|