Feat/selection title reports access #21
@@ -16,6 +16,6 @@
|
||||
"title": { "default": "request-portal" },
|
||||
"description": { "default": "request-portal description" },
|
||||
"officeFabricIconFontName": "Page",
|
||||
"properties": {}
|
||||
"properties": { "notificationsEnabled": true }
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import { type IPropertyPaneConfiguration, PropertyPaneDropdown } from '@microsoft/sp-property-pane';
|
||||
import {
|
||||
type IPropertyPaneConfiguration,
|
||||
PropertyPaneDropdown,
|
||||
PropertyPaneTextField,
|
||||
PropertyPaneToggle
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||
|
||||
@@ -11,7 +16,12 @@ import { IRequestPortalProps } from './components/IRequestPortalProps';
|
||||
import { initSP } from './shared/pnp/pnpjs';
|
||||
import { installErrorLogging } from './shared/diagnostics';
|
||||
import { setImpersonatedIdentity } from './shared/impersonation';
|
||||
import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, setApprovalApiBaseUrl } from './shared/config';
|
||||
import {
|
||||
APPROVAL_API_BASE_URL_LOCAL,
|
||||
APPROVAL_API_BASE_URL_PROD,
|
||||
setApprovalApiBaseUrl,
|
||||
setNotificationSettings
|
||||
} from './shared/config';
|
||||
import { initListPartitions } from './shared/approval';
|
||||
import { ErrorOverlay } from './shared/ui';
|
||||
import { PropertyPaneDebugUser } from './propertyPane/PropertyPaneDebugUser';
|
||||
@@ -28,6 +38,13 @@ export interface IRequestPortalWebPartProps {
|
||||
debugUserDisplayName: string;
|
||||
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||||
approvalApiBaseUrl: string;
|
||||
/**
|
||||
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
||||
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
||||
*/
|
||||
notificationsEnabled?: boolean;
|
||||
/** Непустой адрес — письма только если активный шаг назначен на него (обкатка). Пусто — всем. */
|
||||
notifyOnlyTo?: string;
|
||||
}
|
||||
|
||||
export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequestPortalWebPartProps> {
|
||||
@@ -41,6 +58,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||
this._applyNotificationSettings();
|
||||
|
||||
const element: React.ReactElement = React.createElement(
|
||||
ErrorOverlay,
|
||||
@@ -71,6 +89,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
// панели без перезапуска), но render случается позже onInit.
|
||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||
this._applyNotificationSettings();
|
||||
|
||||
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
|
||||
// формам синхронно (см. shared/listTitles.ts), а формы — лениво подгружаемые
|
||||
@@ -92,7 +111,9 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private _applyNotificationSettings(): void {
|
||||
setNotificationSettings(this.properties.notificationsEnabled !== false, this.properties.notifyOnlyTo || '');
|
||||
}
|
||||
|
||||
private _getEnvironmentMessage(): Promise<string> {
|
||||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||||
@@ -196,6 +217,23 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
||||
]
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
groupName: 'Уведомления',
|
||||
groupFields: [
|
||||
PropertyPaneToggle('notificationsEnabled', {
|
||||
label: 'Уведомления согласующим',
|
||||
onText: 'Включены',
|
||||
offText: 'Выключены',
|
||||
checked: this.properties.notificationsEnabled !== false
|
||||
}),
|
||||
PropertyPaneTextField('notifyOnlyTo', {
|
||||
label: 'Слать только на адрес',
|
||||
description: 'Для обкатки: письмо уходит, только если шаг назначен на этот e-mail. Пусто — всем.',
|
||||
placeholder: 'user@tnsu.kz',
|
||||
disabled: this.properties.notificationsEnabled === false
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -73,18 +73,31 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
||||
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
||||
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
||||
|
||||
// Раздел «Отчёты» — точечный список почт, пока в SharePoint нет группы HR
|
||||
// (см. reportsAccess.ts). Пересчитывается на каждый рендер намеренно: сам
|
||||
// hasReportsAccess — синхронная проверка без сети, а input (effectiveUserEmail)
|
||||
// меняется только по подмене личности в property pane, что и так вызывает
|
||||
// ререндер всей веб-части.
|
||||
const canSeeReports = hasReportsAccess(context);
|
||||
// Раздел «Отчёты» — группа «Отчеты по заявкам» + администраторы (см.
|
||||
// reportsAccess.ts). undefined — ответ ещё не пришёл. Перезапрашиваем при
|
||||
// смене подменённой личности.
|
||||
const [canSeeReports, setCanSeeReports] = React.useState<boolean | undefined>(undefined);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCanSeeReports(undefined);
|
||||
hasReportsAccess(context)
|
||||
.then((v) => {
|
||||
if (!cancelled) setCanSeeReports(v);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCanSeeReports(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context, impersonatedEmail]);
|
||||
|
||||
// Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же
|
||||
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
|
||||
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
|
||||
// Пока ответ не пришёл — не редиректим, иначе участника группы выкинет.
|
||||
React.useEffect(() => {
|
||||
if (route.nav === 'reports' && !canSeeReports) {
|
||||
if (route.nav === 'reports' && canSeeReports === false) {
|
||||
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
||||
}
|
||||
}, [route.nav, canSeeReports]);
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import { AadHttpClient } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL } from '../config';
|
||||
import { FLOW_RESOURCE, FLOW_NOTIFY_URL, getNotificationsEnabled, getNotifyOnlyTo } from '../config';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
|
||||
/**
|
||||
* ВРЕМЕННАЯ ЗАГЛУШКА на период обкатки рассылки: уведомление отправляется,
|
||||
* только если активный шаг назначен на этот адрес. Для всех остальных
|
||||
* согласующих поток не вызывается вообще — письма не будет.
|
||||
*
|
||||
* Ограничение стоит ЗДЕСЬ, на стороне портала, а не в потоке Power Automate,
|
||||
* потому что адресата поток определяет САМ по RowID — тело запроса на выбор
|
||||
* получателя не влияет. Значит, единственный способ не отправить письмо, не
|
||||
* трогая поток, — не дёргать его. Переадресовать письмо на тестовый ящик
|
||||
* отсюда нельзя по той же причине: поток такого параметра не принимает.
|
||||
*
|
||||
* Пустая строка — заглушка снята, уведомления идут всем как обычно.
|
||||
*/
|
||||
const NOTIFY_ONLY_TO = 'gorbenko.pavel@tnsu.kz';
|
||||
|
||||
// Данные формы для тела запроса к потоку (постоянны для формы).
|
||||
export interface INotifyTarget {
|
||||
requestTypeId: number; // RequestTypeId (id типа заявки во внешнем API маршрутов)
|
||||
@@ -42,20 +27,29 @@ async function postNotify(context: WebPartContext, body: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешена ли отправка на согласующего активной строки (см. NOTIFY_ONLY_TO).
|
||||
// Разрешена ли отправка на согласующего активной строки — по свойствам веб-части
|
||||
// notificationsEnabled / notifyOnlyTo (см. setNotificationSettings в config.ts).
|
||||
//
|
||||
// Неизвестный адрес (пользователь не найден, поле пустое, запрос упал) —
|
||||
// НЕ отправляем: пока заглушка включена, сомнение трактуется в пользу
|
||||
// «письмо живому человеку лучше не слать», а не наоборот.
|
||||
// Ограничение стоит ЗДЕСЬ, на стороне портала, а не в потоке Power Automate,
|
||||
// потому что адресата поток определяет САМ по RowID — тело запроса на выбор
|
||||
// получателя не влияет. Значит, единственный способ не отправить письмо, не
|
||||
// трогая поток, — не дёргать его.
|
||||
//
|
||||
// При заданном notifyOnlyTo неизвестный адрес (пользователь не найден, поле
|
||||
// пустое, запрос упал) — НЕ отправляем: сомнение трактуется в пользу «письмо
|
||||
// живому человеку лучше не слать».
|
||||
async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise<boolean> {
|
||||
if (!NOTIFY_ONLY_TO) return true;
|
||||
if (!getNotificationsEnabled()) return false;
|
||||
|
||||
const onlyTo = getNotifyOnlyTo();
|
||||
if (!onlyTo) return true;
|
||||
|
||||
const email = await approval.resolveUserEmail(approverId);
|
||||
if (email.toLowerCase() === NOTIFY_ONLY_TO.toLowerCase()) return true;
|
||||
if (email.toLowerCase() === onlyTo.toLowerCase()) return true;
|
||||
|
||||
console.warn(
|
||||
`[Оповещение] Включена заглушка рассылки: письмо для «${email || 'адрес неизвестен'}» не отправлено ` +
|
||||
`(разрешён только ${NOTIFY_ONLY_TO})`
|
||||
`[Оповещение] Рассылка ограничена одним адресом: письмо для «${email || 'адрес неизвестен'}» не отправлено ` +
|
||||
`(разрешён только ${onlyTo})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,28 @@ export function getApprovalApiBaseUrl(): string {
|
||||
return _approvalApiBaseUrl;
|
||||
}
|
||||
|
||||
// Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts).
|
||||
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
||||
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
||||
// enabled — false выключает рассылку целиком;
|
||||
// onlyTo — непустой адрес: письма уходят ТОЛЬКО если активный шаг назначен
|
||||
// на него (обкатка рассылки); пусто — всем.
|
||||
let _notificationsEnabled = true;
|
||||
let _notifyOnlyTo = '';
|
||||
|
||||
export function setNotificationSettings(enabled: boolean, onlyTo: string): void {
|
||||
_notificationsEnabled = enabled;
|
||||
_notifyOnlyTo = (onlyTo || '').trim();
|
||||
}
|
||||
|
||||
export function getNotificationsEnabled(): boolean {
|
||||
return _notificationsEnabled;
|
||||
}
|
||||
|
||||
export function getNotifyOnlyTo(): string {
|
||||
return _notifyOnlyTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочники (Dictionary API) — тот же gateway api.tnsu.kz и тот же AAD-app
|
||||
* (APPROVAL_API_CLIENT_ID), но другой путь/сервис. Используется, например, для
|
||||
|
||||
@@ -1,34 +1,64 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { effectiveUserEmail } from '../impersonation';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import '@pnp/sp/site-groups/web';
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import { effectiveUserEmail, getImpersonatedEmail } from '../impersonation';
|
||||
|
||||
/**
|
||||
* Кто видит раздел «Отчёты» в боковом меню. Пока это фиксированный список
|
||||
* почт — группы HR в SharePoint ещё нет (её заведут отдельно); появится —
|
||||
* список ниже заменится на реальную проверку членства (см. TODO).
|
||||
* SharePoint-группа «Отчеты по заявкам» на сайте данных (/sites/requests) —
|
||||
* её участники видят раздел «Отчёты». Id, а не Title: Title можно
|
||||
* переименовать в SharePoint без последствий для кода.
|
||||
*/
|
||||
const REPORTS_GROUP_ID = 199;
|
||||
|
||||
/**
|
||||
* Администраторы портала — видят раздел независимо от состава группы.
|
||||
*
|
||||
* Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из
|
||||
* Title сайта: у части учёток встречаются устаревшие site user записи со
|
||||
* старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас
|
||||
* рядом с текущим trishina.mariya@tnsu.kz).
|
||||
*/
|
||||
const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
|
||||
const ALWAYS_ALLOWED_EMAILS: ReadonlySet<string> = new Set([
|
||||
'trishina.mariya@tnsu.kz', // Мария Тришина
|
||||
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
||||
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
||||
'sarniyazova.assilya@tnsu.kz', // Асилья Сарниязова
|
||||
]);
|
||||
|
||||
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
|
||||
// её заведут — тогда список выше станет фолбэком для тех троих, кто должен
|
||||
// видеть раздел независимо от состава группы (администраторы портала).
|
||||
const cache = new Map<string, Promise<boolean>>();
|
||||
|
||||
async function fetchIsGroupMember(email: string): Promise<boolean> {
|
||||
// Под подменой личности спрашиваем состав группы, а не currentUser: иначе
|
||||
// проверялся бы реальный пользователь. Состав группы виден только её
|
||||
// участникам и владельцам сайта — остальным запрос вернёт ошибку.
|
||||
if (getImpersonatedEmail()) {
|
||||
const users = await getSP().web.siteGroups.getById(REPORTS_GROUP_ID).users.select('Email')();
|
||||
return users.some((u) => (u.Email || '').trim().toLowerCase() === email);
|
||||
}
|
||||
const groups = await getSP().web.currentUser.groups.select('Id')();
|
||||
return groups.some((g) => g.Id === REPORTS_GROUP_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Есть ли у текущего пользователя доступ к разделу «Отчёты». Использует
|
||||
* effectiveUserEmail — то есть учитывает отладочную подмену личности (см.
|
||||
* impersonation.ts): под подменённым пользователем раздел виден/не виден
|
||||
* так же, как было бы у него самого.
|
||||
* impersonation.ts). Ответ кешируется на сеанс по email; ошибка запроса = нет
|
||||
* доступа и не кешируется, чтобы временный сбой не «залип».
|
||||
*/
|
||||
export function hasReportsAccess(context: WebPartContext): boolean {
|
||||
export function hasReportsAccess(context: WebPartContext): Promise<boolean> {
|
||||
const email = effectiveUserEmail(context).trim().toLowerCase();
|
||||
return email.length > 0 && ALLOWED_EMAILS.has(email);
|
||||
if (!email) return Promise.resolve(false);
|
||||
if (ALWAYS_ALLOWED_EMAILS.has(email)) return Promise.resolve(true);
|
||||
|
||||
let pending = cache.get(email);
|
||||
if (!pending) {
|
||||
pending = fetchIsGroupMember(email).catch(() => {
|
||||
cache.delete(email);
|
||||
return false;
|
||||
});
|
||||
cache.set(email, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# `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` — только
|
||||
доступное вам.
|
||||
Generated
-7908
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "sp-tools",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Запросы к SharePoint портала заявок для разбора: Search, managed properties, партиции списков",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"login": "node sp.mjs login",
|
||||
"status": "node sp.mjs status",
|
||||
"props": "node sp.mjs props",
|
||||
"lists": "node sp.mjs lists"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnp/cli-microsoft365": "^11.10.0"
|
||||
}
|
||||
}
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sp — тонкая обёртка над CLI for Microsoft 365 для РАЗБОРА, а не для правки данных.
|
||||
*
|
||||
* Зачем вообще: разбирая производительность портала, всё время нужно задать
|
||||
* SharePoint один и тот же вопрос («а что реально отдаёт индекс?», «сколько
|
||||
* партиций у типа?») и получить ответ текстом, который можно приложить к задаче.
|
||||
* Через браузерную консоль это ручная работа с копипастой; здесь — команда.
|
||||
*
|
||||
* Почему device code, а не секрет приложения: пароль и секрет через этот
|
||||
* инструмент не проходят вообще. `sp login` печатает код, пользователь вводит
|
||||
* его в браузере сам, CLI дальше держит токен у себя. В репозитории не оседает
|
||||
* ничего секретного.
|
||||
*
|
||||
* Почему сырой REST через токен, а не готовые команды `m365 spo`: группа
|
||||
* `m365 search` — это Microsoft Search (Graph), совсем другой API. Нам нужен
|
||||
* ИМЕННО SharePoint Search REST с SelectProperties — тот же, что дёргает портал
|
||||
* (см. myRequests.service.ts). Тело запроса взято один в один из PnPjs
|
||||
* (@pnp/sp/search/query.js): `{ request: { ...query } }`, массивы плоские,
|
||||
* odata=nometadata. Иначе инструмент мерил бы не то, что портал.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = join(HERE, 'node_modules', '@pnp', 'cli-microsoft365', 'dist', 'index.js');
|
||||
|
||||
// Значения по умолчанию — те же, что в src/webparts/requestPortal/shared/config.ts.
|
||||
// Переопределяются переменными окружения, чтобы не править файл ради другого стенда.
|
||||
const SITE = process.env.SP_SITE || 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
const APP_ID = process.env.SP_APP_ID || process.env.CLIMICROSOFT365_ENTRAAPPID || '';
|
||||
const TENANT = process.env.SP_TENANT || process.env.CLIMICROSOFT365_TENANT || 'common';
|
||||
|
||||
/** Хост тенанта — именно он, а не адрес сайта, является ресурсом для токена. */
|
||||
const RESOURCE = new URL(SITE).origin;
|
||||
|
||||
function m365(args, { inherit = false } = {}) {
|
||||
return execFileSync(process.execPath, [CLI, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
});
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[sp] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let cachedToken;
|
||||
function token() {
|
||||
if (cachedToken) return cachedToken;
|
||||
try {
|
||||
cachedToken = m365(['util', 'accesstoken', 'get', '-r', RESOURCE, '-o', 'text']).trim();
|
||||
} catch (e) {
|
||||
fail(
|
||||
`не удалось получить токен для ${RESOURCE}.\n` +
|
||||
` Скорее всего нет входа — выполни: node sp.mjs login\n` +
|
||||
` ${String(e.stderr || e.message).trim().split('\n')[0]}`
|
||||
);
|
||||
}
|
||||
if (!cachedToken) fail(`пустой токен для ${RESOURCE} — выполни: node sp.mjs login`);
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
async function spFetch(path, init = {}) {
|
||||
const url = path.startsWith('http') ? path : `${SITE}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token()}`,
|
||||
accept: 'application/json;odata=nometadata',
|
||||
...(init.body ? { 'content-type': 'application/json;odata=nometadata' } : {}),
|
||||
...(init.headers || {})
|
||||
}
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) fail(`${res.status} ${res.statusText}\n${text.slice(0, 800)}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Команды
|
||||
|
||||
async function cmdLogin() {
|
||||
if (!APP_ID) {
|
||||
fail(
|
||||
'не задан SP_APP_ID.\n' +
|
||||
' CLI for Microsoft 365 с 7-й версии не возит собственное приложение —\n' +
|
||||
' нужен свой Entra app (см. README.md, раздел «Разовая подготовка»).'
|
||||
);
|
||||
}
|
||||
console.log(`[sp] вход в ${TENANT} приложением ${APP_ID}. Код появится ниже — введи его в браузере.`);
|
||||
m365(['login', '--appId', APP_ID, '--tenant', TENANT, '--authType', 'deviceCode'], { inherit: true });
|
||||
}
|
||||
|
||||
function cmdStatus() {
|
||||
console.log(m365(['status', '-o', 'text']).trim());
|
||||
console.log(`[sp] сайт: ${SITE}`);
|
||||
console.log(`[sp] ресурс токена: ${RESOURCE}`);
|
||||
}
|
||||
|
||||
function cmdLogout() {
|
||||
m365(['logout'], { inherit: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* SharePoint Search — тот же вызов, что делает портал.
|
||||
*
|
||||
* POST /_api/search/postquery, а не GET /query: KQL портала перечисляет через OR
|
||||
* адреса ВСЕХ партиций, и в query-строке это упирается в предел длины URL.
|
||||
*/
|
||||
async function cmdSearch(argv) {
|
||||
const kql = argv._[0];
|
||||
if (!kql) fail('нужен KQL: node sp.mjs search "<запрос>" [--select a,b] [--rows N]');
|
||||
|
||||
const select = (argv.select || 'ListItemID,Path,Title').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const rows = Number(argv.rows || 10);
|
||||
|
||||
const data = await spFetch('/_api/search/postquery', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request: { Querytext: kql, RowLimit: rows, SelectProperties: select, TrimDuplicates: false }
|
||||
})
|
||||
});
|
||||
|
||||
const relevant = data?.PrimaryQueryResult?.RelevantResults;
|
||||
const rawRows = relevant?.Table?.Rows ?? [];
|
||||
const parsed = rawRows.map((r) => {
|
||||
const cells = r.Cells?.results ?? r.Cells ?? [];
|
||||
return select.reduce((acc, key) => {
|
||||
const cell = cells.filter((c) => c.Key === key)[0];
|
||||
// Различаем «свойство не Retrievable» и «значение пустое» — ради этого
|
||||
// различия инструмент и писался, схлопывать их в '' нельзя.
|
||||
acc[key] = !cell ? '<нет в ответе>' : cell.Value === null ? '<null>' : cell.Value;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
console.log(`[sp] в индексе ${relevant?.TotalRows ?? 0}, показано ${parsed.length}`);
|
||||
console.log(JSON.stringify(parsed, null, 1));
|
||||
}
|
||||
|
||||
/** Произвольный GET к REST — для всего, чего нет отдельной командой. */
|
||||
async function cmdGet(argv) {
|
||||
const path = argv._[0];
|
||||
if (!path) fail('нужен путь: node sp.mjs get "/_api/web/lists?$select=Title"');
|
||||
console.log(JSON.stringify(await spFetch(path), null, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Все списки сайта с адресами — тем же запросом, которым портал резолвит
|
||||
* списки согласования (см. shared/approval/approvalListLocator.ts). Заодно
|
||||
* показывает, сколько на самом деле партиций: это и есть P из оценок.
|
||||
*/
|
||||
async function cmdLists() {
|
||||
const data = await spFetch(
|
||||
"/_api/web/lists?$select=Title,ItemCount,Hidden,RootFolder/ServerRelativeUrl&$expand=RootFolder&$top=5000"
|
||||
);
|
||||
const all = data.value ?? [];
|
||||
const portal = all.filter((l) => !l.Hidden && /—\s*(данные|согласование)\s*$/.test(l.Title || ''));
|
||||
|
||||
portal
|
||||
.sort((a, b) => String(a.Title).localeCompare(String(b.Title)))
|
||||
.forEach((l) => {
|
||||
console.log(`${String(l.ItemCount).padStart(6)} ${l.RootFolder?.ServerRelativeUrl} ${l.Title}`);
|
||||
});
|
||||
|
||||
const data_ = portal.filter((l) => /данные\s*$/.test(l.Title)).length;
|
||||
console.log(`[sp] всего списков на сайте ${all.length}; портальных ${portal.length} (данные: ${data_})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отдаёт ли индекс поля заявки (Retrievable) — от этого зависит, можно ли
|
||||
* заменить REST-дочитывание (loadByIds) на SelectProperties самого Search.
|
||||
*
|
||||
* Queryable и Retrievable у managed property — РАЗНЫЕ флаги: то, по чему можно
|
||||
* фильтровать, не обязано приходить в ответе. Отбираем по самому
|
||||
* RequestStatusOWSCHCS, поэтому найденное гарантированно является заявками
|
||||
* портала, а не случайными элементами сайта.
|
||||
*/
|
||||
async function cmdProps(argv) {
|
||||
const props = (
|
||||
argv.select ||
|
||||
'ListItemID,Path,Title,Created,Author,AuthorOWSUSER,RequestStatusOWSCHCS,RequestUniqueCodeOWSTEXT,LastModifiedTime'
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim());
|
||||
|
||||
const statuses = ['На согласовании', 'Черновик', 'Согласовано', 'Возвращена на доработку'];
|
||||
const kql = `Path:"${SITE}/*" AND (${statuses.map((s) => `RequestStatusOWSCHCS:"${s}"`).join(' OR ')})`;
|
||||
|
||||
await cmdSearch({ _: [kql], select: props.join(','), rows: argv.rows || 3 });
|
||||
console.log(
|
||||
'[sp] <нет в ответе> или <null> у свойства = оно НЕ Retrievable, для этого поля REST остаётся.'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgv(args) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const a = args[i];
|
||||
if (a.startsWith('--')) {
|
||||
const [key, inline] = a.slice(2).split('=');
|
||||
out[key] = inline ?? (args[i + 1] && !args[i + 1].startsWith('--') ? args[(i += 1)] : true);
|
||||
} else {
|
||||
out._.push(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const USAGE = `sp — запросы к SharePoint портала заявок (только чтение)
|
||||
|
||||
node sp.mjs login вход по device code (код вводишь в браузере сам)
|
||||
node sp.mjs status кто залогинен и куда смотрим
|
||||
node sp.mjs logout
|
||||
|
||||
node sp.mjs search "<KQL>" [--select a,b,c] [--rows N]
|
||||
node sp.mjs props [--select a,b,c] [--rows N] отдаёт ли индекс поля заявки
|
||||
node sp.mjs lists списки сайта и их размеры (партиции)
|
||||
node sp.mjs get "/_api/web/..." произвольный GET
|
||||
|
||||
Окружение: SP_SITE, SP_APP_ID, SP_TENANT (см. README.md)`;
|
||||
|
||||
const [, , command, ...rest] = process.argv;
|
||||
const argv = parseArgv(rest);
|
||||
|
||||
const commands = {
|
||||
login: cmdLogin,
|
||||
status: cmdStatus,
|
||||
logout: cmdLogout,
|
||||
search: cmdSearch,
|
||||
props: cmdProps,
|
||||
lists: cmdLists,
|
||||
get: cmdGet
|
||||
};
|
||||
|
||||
if (!command || !commands[command]) {
|
||||
console.log(USAGE);
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
Promise.resolve(commands[command](argv)).catch((e) => fail(String(e.stack || e.message)));
|
||||
Reference in New Issue
Block a user