4 Commits

Author SHA1 Message Date
gorbenko.pavel dd13e337f2 refactor(RecruitingHire): CandidateName/HireDate/ReplacedEmployee -> Hire* (обход коллизии Search)
Tests / frontend-test (pull_request) Successful in 1m26s
Те же три поля internalName делит форма «Реферальная программа» — Search
индексировал managed property только у одного из двух списков (см. sp-write
sitefield/field: разные Id при одинаковом имени). Тот же приём, что для
VacancyOpenReason в RecruitingSelection: переименовать в уникальное имя,
данные перенести.

  CandidateName     -> HireCandidateName
  HireDate          -> HireHireDate
  ReplacedEmployee  -> HireReplacedEmployee

На этот раз — genuine столбцы САЙТА (createsitefield + linksitefield в
sp-write), а не просто колонки списка: так задумано и в самом провижинере
(Ensure-SiteColumn — простые типы должны быть общим эталоном), просто на
практике это не всегда соблюдается (Position тоже разъехался по Id между
списками). Проверено: Id столбца на сайте и на списке совпадают.

Данные на живом сайте перенесены отдельным скриптом (migrate-hire-fields.sh,
разово, вне репозитория) — все 37 элементов, сверено на трёх.

Задело WorkplaceSetup/prefill.ts — оно читает CandidateName/HireDate ИЗ
загруженной заявки на приём для кросс-форменного предзаполнения. Referral
(та же коллизия, другая форма) НЕ трогал.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:41:18 +05:00
gorbenko.pavel bd7d83b9ff refactor(RecruitingSelection): OpenReason -> VacancyOpenReason (обход коллизии Search)
Колонка OpenReason/OpenReasonDetails называлась одинаково с полями в других
списках сайта, из-за чего Search индексировал managed property только у ОДНОГО
из них — не у этого (см. sp-write props/field, разбирались 2026-08-28/29 в
tnsu-approval-route). Переименование снимает коллизию: VacancyOpenReasonOWSMTXT/
VacancyOpenReasonDetailsOWSTEXT теперь индексируются нормально, проверено.

Данные из старых колонок на живом сайте уже перенесены отдельным скриптом
(migrate-openreason.sh, разово, вне репозитория). provisioning.json обновлён на
новые internalName — он только добавляет колонки, никогда не удаляет, так что
старые OpenReason/OpenReasonDetails на сайте провижининг сам не уберёт.

load.ts/submit.ts менять не пришлось — оба читают/пишут поля по ключам
IFormData обобщённо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:18:48 +05:00
gorbenko.pavel 0701736e23 docs(tools/sp): корневая причина непроиндексированных полей + правка про SortList
Причина найдена: одноимённые колонки в разных списках — разные поля со своими
crawled property, автосозданное managed property привязано к одному из них.
Добавлена таблица, показывающая, что PositionOWSTEXT отдаёт значения в одном
списке и молчит в другом, — иначе картина выглядит бессмыслицей.

Исправлено неверное утверждение из предыдущего коммита: проверять существование
managed property через SortList нельзя. Sortable — независимый от Retrievable
флаг, у автосозданных ows_* обычно выключен; SortList падает 400/500 и на
заведомо живом PositionOWSTEXT. Рабочий способ — сверка Search с REST, её делает
`sp-write props`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:22:48 +05:00
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
12 changed files with 114 additions and 46 deletions
@@ -185,7 +185,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
(login) => approval.getJobTitle(login) (login) => approval.getJobTitle(login)
); );
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0; const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0;
const validate = (): string | undefined => { const validate = (): string | undefined => {
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.'; if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
@@ -16,8 +16,8 @@ function parseJsonArray<T>(raw: unknown): T[] {
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке. // Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
const SCALAR_FIELDS: (keyof IFormData)[] = [ const SCALAR_FIELDS: (keyof IFormData)[] = [
'Phone', 'HireType', 'ReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode', 'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
'CandidateName', 'HireDate', 'Position', 'WorkKind', 'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind',
'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod', 'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther', 'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
'AgencyName', 'AgencyTerms', 'AgencyName', 'AgencyTerms',
@@ -58,7 +58,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
const value = item[key]; const value = item[key];
if (value === null || value === undefined) { if (value === null || value === undefined) {
writable[key] = ''; writable[key] = '';
} else if (key === 'HireDate' && typeof value === 'string') { } else if (key === 'HireHireDate' && typeof value === 'string') {
writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date
} else { } else {
writable[key] = String(value); writable[key] = String(value);
@@ -18,7 +18,16 @@ export interface IFormData {
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
Phone: string; Phone: string;
HireType: string; HireType: string;
ReplacedEmployee: string; /**
* Префикс Hire — обход коллизии managed properties: этот internalName
* (без префикса) используется и в форме «Реферальная программа» —
* ДРУГИМ полем с ДРУГИМ Id. Search индексировал managed property только у
* одного из двух списков сайта (см. sp-write props/field в tools; тот же
* приём, что VacancyOpenReason в RecruitingSelection). CandidateName и
* HireDate ниже переименованы по той же причине; ReferralForm НЕ трогать —
* там имя осталось прежним.
*/
HireReplacedEmployee: string;
// Проект/объект (справочник /Project) — справочное поле, на маршрут // Проект/объект (справочник /Project) — справочное поле, на маршрут
// согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer). // согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer).
Project: string; Project: string;
@@ -30,8 +39,8 @@ export interface IFormData {
SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist) SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist)
SelectionRequestCode: string; // уникальный код для подписи/ссылки SelectionRequestCode: string; // уникальный код для подписи/ссылки
// Кандидат // Кандидат
CandidateName: string; HireCandidateName: string;
HireDate: string; HireHireDate: string;
Position: string; Position: string;
WorkKind: string; WorkKind: string;
/** /**
@@ -75,15 +84,15 @@ export const EMPTY_DATA: IFormData = {
Companies: [], Companies: [],
Phone: '', Phone: '',
HireType: '', HireType: '',
ReplacedEmployee: '', HireReplacedEmployee: '',
Project: '', Project: '',
ProjectId: '', ProjectId: '',
ProjectCode: '', ProjectCode: '',
SelectionRequestId: '', SelectionRequestId: '',
SelectionRequestListId: '', SelectionRequestListId: '',
SelectionRequestCode: '', SelectionRequestCode: '',
CandidateName: '', HireCandidateName: '',
HireDate: '', HireHireDate: '',
Position: '', Position: '',
WorkKind: '', WorkKind: '',
Department: '', Department: '',
@@ -8,7 +8,7 @@
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] }, { "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
{ "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" }, { "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
{ "internalName": "Project", "title": "Проект", "type": "Text" }, { "internalName": "Project", "title": "Проект", "type": "Text" },
{ "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" }, { "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" },
@@ -18,8 +18,8 @@
{ "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" }, { "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" },
{ "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" }, { "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" },
{ "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true }, { "internalName": "HireCandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true }, { "internalName": "HireHireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" }, { "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
{ "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" }, { "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" },
@@ -10,7 +10,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
const handleHireTypeChange = (value: string): void => { const handleHireTypeChange = (value: string): void => {
set('HireType', value); set('HireType', value);
if (value !== HIRE_TYPE_REPLACEMENT) { if (value !== HIRE_TYPE_REPLACEMENT) {
set('ReplacedEmployee', ''); // при не-замене очищаем скрытое поле set('HireReplacedEmployee', ''); // при не-замене очищаем скрытое поле
} }
}; };
@@ -54,11 +54,11 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
/> />
</FormField> </FormField>
{isReplacement && ( {isReplacement && (
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="ReplacedEmployee" full> <FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="HireReplacedEmployee" full>
<TextInput <TextInput
id="ReplacedEmployee" id="HireReplacedEmployee"
value={data.ReplacedEmployee} value={data.HireReplacedEmployee}
onChange={(v) => set('ReplacedEmployee', v)} onChange={(v) => set('HireReplacedEmployee', v)}
/> />
</FormField> </FormField>
)} )}
@@ -5,11 +5,11 @@ import { ISectionProps } from '../model';
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => ( export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
<CollapseSection title="Кандидат" icon="people"> <CollapseSection title="Кандидат" icon="people">
<FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full> <FormField label="Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
<TextInput id="CandidateName" value={data.CandidateName} onChange={(v) => set('CandidateName', v)} /> <TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
</FormField> </FormField>
<FormField label="Дата приёма" htmlFor="HireDate"> <FormField label="Дата приёма" htmlFor="HireHireDate">
<TextInput id="HireDate" type="date" value={data.HireDate} onChange={(v) => set('HireDate', v)} /> <TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
</FormField> </FormField>
<FormField label="Должность" htmlFor="Position" required> <FormField label="Должность" htmlFor="Position" required>
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} /> <TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
@@ -16,7 +16,7 @@ export function hireApprovalService(listId?: string): ApprovalListService {
/** Осмысленный заголовок элемента списка (системная колонка Title). */ /** Осмысленный заголовок элемента списка (системная колонка Title). */
function buildTitle(data: IFormData): string { function buildTitle(data: IFormData): string {
return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — '); return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — ');
} }
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */ /** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
@@ -18,8 +18,15 @@ export interface IFormData {
Responsible: string; Responsible: string;
SubmitDate: string; SubmitDate: string;
ClosingDeadline: string; ClosingDeadline: string;
OpenReason: string; /**
OpenReasonDetails: string; * Префикс Vacancy — не для читаемости, а обход коллизии managed properties:
* колонка называлась просто OpenReason, и Search индексировал её только у
* ОДНОГО из списков сайта с таким internal name (см. sp-write props/field в
* tools). Переименовали и перенесли данные скриптом (2026-08-29) — трогать
* имя обратно нельзя, коллизия вернётся.
*/
VacancyOpenReason: string;
VacancyOpenReasonDetails: string;
Companies: ICompany[]; Companies: ICompany[];
ObjectProject: string; ObjectProject: string;
ObjectProjectId: string; ObjectProjectId: string;
@@ -92,8 +99,8 @@ export const EMPTY_DATA: IFormData = {
Responsible: '', Responsible: '',
SubmitDate: '', SubmitDate: '',
ClosingDeadline: '', ClosingDeadline: '',
OpenReason: '', VacancyOpenReason: '',
OpenReasonDetails: '', VacancyOpenReasonDetails: '',
Companies: [], Companies: [],
ObjectProject: '', ObjectProject: '',
ObjectProjectId: '', ObjectProjectId: '',
@@ -149,7 +156,7 @@ export const OPEN_REASON_REPLACE = 'Замена уволенного сотру
export const OPEN_REASON_TRANSFER = 'Перевод сотрудника'; export const OPEN_REASON_TRANSFER = 'Перевод сотрудника';
export const OPEN_REASON_NEW = 'Новая штатная единица'; export const OPEN_REASON_NEW = 'Новая штатная единица';
/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */ /** Варианты «Причина открытия вакансии» — совпадают с choices колонки VacancyOpenReason. */
export const OPEN_REASON_OPTIONS: ISelectOption[] = [ export const OPEN_REASON_OPTIONS: ISelectOption[] = [
{ value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE }, { value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE },
{ value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER }, { value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER },
@@ -5,8 +5,8 @@
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true }, { "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] }, { "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] },
{ "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" }, { "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" }, { "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" }, { "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" }, { "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
@@ -11,7 +11,7 @@ import {
} from '../model'; } from '../model';
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => { export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => {
const reason = data.OpenReason; const reason = data.VacancyOpenReason;
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW; const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
const reasonDetailsLabel = const reasonDetailsLabel =
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину'; reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
@@ -45,24 +45,24 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
onChange={(v) => set('ClosingDeadline', v)} onChange={(v) => set('ClosingDeadline', v)}
/> />
</FormField> </FormField>
<FormField label="1.5 Причина открытия вакансии" htmlFor="OpenReason"> <FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason">
<Select <Select
id="OpenReason" id="VacancyOpenReason"
value={data.OpenReason} value={data.VacancyOpenReason}
onChange={(v) => { onChange={(v) => {
set('OpenReason', v); set('VacancyOpenReason', v);
if (v === '' || v === OPEN_REASON_NEW) set('OpenReasonDetails', ''); if (v === '' || v === OPEN_REASON_NEW) set('VacancyOpenReasonDetails', '');
}} }}
options={OPEN_REASON_OPTIONS} options={OPEN_REASON_OPTIONS}
placeholder="— выберите —" placeholder="— выберите —"
/> />
</FormField> </FormField>
{showReasonDetails && ( {showReasonDetails && (
<FormField label={reasonDetailsLabel} htmlFor="OpenReasonDetails"> <FormField label={reasonDetailsLabel} htmlFor="VacancyOpenReasonDetails">
<TextInput <TextInput
id="OpenReasonDetails" id="VacancyOpenReasonDetails"
value={data.OpenReasonDetails} value={data.VacancyOpenReasonDetails}
onChange={(v) => set('OpenReasonDetails', v)} onChange={(v) => set('VacancyOpenReasonDetails', v)}
/> />
</FormField> </FormField>
)} )}
@@ -9,12 +9,12 @@ export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
const { data: hire } = await loadHireRequest(hireId); const { data: hire } = await loadHireRequest(hireId);
return { return {
...EMPTY_DATA, ...EMPTY_DATA,
FullName: hire.CandidateName, FullName: hire.HireCandidateName,
Phone: hire.Phone, Phone: hire.Phone,
Position: hire.Position, Position: hire.Position,
Department: hire.Department, Department: hire.Department,
WorkKind: hire.WorkKind, WorkKind: hire.WorkKind,
Location: hire.SiteLocation, Location: hire.SiteLocation,
StartDate: hire.HireDate StartDate: hire.HireHireDate
}; };
} }
+56 -4
View File
@@ -13,10 +13,14 @@
пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у
себя. Ни пароля, ни client secret в репозитории и в командах нет. себя. Ни пароля, ни client secret в репозитории и в командах нет.
**Работаем от имени пользователя, а не приложения.** Это не удобство, а требование: **Работаем от имени пользователя, а не приложения.** Главное, что это даёт: видно
SharePoint Search REST (`/_api/search/query`) под app-only-токеном обычно не отдаёт ровно то же, что видит пользователь в портале, — а у заявок портала это принципиально,
ничего, ему нужен пользовательский контекст. А Search — главное, что здесь измеряют. потому что права разорваны НА КАЖДОМ элементе (см. «Грабли» ниже).
Побочный плюс: видно ровно то же, что видит пользователь в портале.
Раньше здесь было написано, что Search под app-only-токеном «обычно не отдаёт ничего».
Проверено 2026-08-28 на сертификате `CN=PnP-SharePoint-Sync` (тот же, что у бэкенда
`tnsu-approval-route`): **отдаёт** — и `postquery`, и кастомные managed properties.
Так что app-only Search как таковой рабочий; ограничения у него другие, см. «Грабли».
## Разовая подготовка ## Разовая подготовка
@@ -96,3 +100,51 @@ claims-логине частей будет четыре. Разбирать с
партиций и в query-строке упирается в предел длины URL. Форма тела взята один в один партиций и в query-строке упирается в предел длины URL. Форма тела взята один в один
из PnPjs (`@pnp/sp/search/query.js`): `{ request: { ...query } }`, массивы плоские, из PnPjs (`@pnp/sp/search/query.js`): `{ request: { ...query } }`, массивы плоские,
`odata=nometadata`. Иначе инструмент мерил бы не то, что портал. `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` — только
доступное вам.