From 849aa7964dee25be04c56901201a85be8811ec5c Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 10:11:26 +0500 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=20=C2=AB=D0=9E=D1=82=D1=87=D1=91=D1=82=D1=8B=C2=BB=20?= =?UTF-8?q?=D0=B2=20=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8E=20=E2=80=94=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D0=B2=D0=B0=D0=BA=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункт меню виден только заданному списку почт (Тришина, Городецкий, Горбенко) — группы 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 --- .../components/RequestPortal.tsx | 23 ++++++ .../reports/ReportsFeature.module.scss | 19 +++++ .../features/reports/ReportsFeature.tsx | 80 +++++++++++++++++++ .../shared/reports/reports.service.ts | 69 ++++++++++++++++ .../shared/reports/reportsAccess.ts | 33 ++++++++ src/webparts/requestPortal/shared/routing.ts | 4 +- 6 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/webparts/requestPortal/features/reports/ReportsFeature.module.scss create mode 100644 src/webparts/requestPortal/features/reports/ReportsFeature.tsx create mode 100644 src/webparts/requestPortal/shared/reports/reports.service.ts create mode 100644 src/webparts/requestPortal/shared/reports/reportsAccess.ts diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 97ecd09..a1bce34 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -36,6 +36,8 @@ import { SearchView } from '../features/myRequests/SearchView'; import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; import { IMyRequest } from '../features/myRequests/models/IMyRequest'; import { isEditableStatus } from '../shared/approval'; +import { hasReportsAccess } from '../shared/reports/reportsAccess'; +import { ReportsFeature } from '../features/reports/ReportsFeature'; // Оболочка портала: тёмное боковое меню (разделы) + основная область. // Логики фич тут нет — файл общий, меняется редко. @@ -70,6 +72,22 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context const [mobileNavOpen, setMobileNavOpen] = React.useState(false); const [navCounts, setNavCounts] = React.useState(undefined); + // Раздел «Отчёты» — точечный список почт, пока в SharePoint нет группы HR + // (см. reportsAccess.ts). Пересчитывается на каждый рендер намеренно: сам + // hasReportsAccess — синхронная проверка без сети, а input (effectiveUserEmail) + // меняется только по подмене личности в property pane, что и так вызывает + // ререндер всей веб-части. + const canSeeReports = hasReportsAccess(context); + + // Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же + // раздел, что скрыт из навигации, должен быть недоступен и так: без этого + // редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку. + React.useEffect(() => { + if (route.nav === 'reports' && !canSeeReports) { + navigate({ mode: 'submit', nav: 'new' }, { replace: true }); + } + }, [route.nav, canSeeReports]); + // Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма // узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/ // EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не @@ -278,6 +296,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context }; // Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts). + // «Отчёты» — только у тех, кому разрешено (см. canSeeReports выше): скрыт из + // меню совсем, а не показан неактивным, — раздела для остальных как бы не существует. const navItems: ISidebarItem[] = [ { id: 'new', title: 'Новая заявка', icon: 'plus' }, { id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals }, @@ -285,6 +305,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context { id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, { id: 'search', title: 'Поиск', icon: 'search' }, + ...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []), ]; return ( @@ -375,6 +396,8 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context )} + {route.nav === 'reports' && canSeeReports && } + {LIST_VIEWS[route.nav] !== undefined && ( = ({ context }) => { + const today = todayIso(); + const [from, setFrom] = React.useState(firstDayOfMonthIso()); + const [to, setTo] = React.useState(today); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + + const periodInvalid = !!from && !!to && from > to; + + const download = (): void => { + if (!from || !to || periodInvalid) return; + + setLoading(true); + setError(''); + downloadVacancyReport(context, from, to) + .catch((e: Error) => setError(e.message || 'Не удалось сформировать отчёт')) + .finally(() => setLoading(false)); + }; + + return ( +
+ + + + + + + + + + + {periodInvalid && Конец периода раньше его начала.} + {error && {error}} + +
+ +
+ +

+ Период отбирает заявки по дате подачи заявки на подбор. Заявки на приём в отчёт + попадают независимо от периода — по факту закрытия вакансии. +

+
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/reports/reports.service.ts b/src/webparts/requestPortal/shared/reports/reports.service.ts new file mode 100644 index 0000000..f6bf1fa --- /dev/null +++ b/src/webparts/requestPortal/shared/reports/reports.service.ts @@ -0,0 +1,69 @@ +import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config'; + +// Обращения к api/reports approval-route API — по тому же принципу, что и +// approvalRoute.service.ts (AadHttpClient, SSO текущего пользователя), но +// ответ — не JSON, а сам файл (см. ReportsController.Vacancies). + +/** Отчёт по вакансиям за период: даты YYYY-MM-DD (как в TextInput type="date"). */ +export async function downloadVacancyReport( + context: WebPartContext, + from: string, + to: string +): Promise { + const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); + const url = `${getApprovalApiBaseUrl()}/api/reports/vacancies?from=${from}&to=${to}`; + const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1); + + if (!response.ok) { + throw new Error(await extractErrorMessage(response)); + } + + const blob = await response.blob(); + // Имя файла собираем сами, а не читаем Content-Disposition: approval-route + // не объявляет его в Access-Control-Expose-Headers (см. Program.cs, CORS), + // и без этого браузер отдал бы fetch-у пустой заголовок. Формат — тот же, + // что и в VacancyReportWorkbook.FileName на бэкенде. + saveBlob(blob, vacancyReportFileName(from, to)); +} + +/** «Отчет по вакансиям dd.MM.yyyy-dd.MM.yyyy.xlsx» — как в VacancyReportWorkbook.FileName. */ +function vacancyReportFileName(from: string, to: string): string { + return `Отчет по вакансиям ${formatRu(from)}-${formatRu(to)}.xlsx`; +} + +function formatRu(isoDate: string): string { + const [year, month, day] = isoDate.split('-'); + return `${day}.${month}.${year}`; +} + +/** + * 400 от ReportsController приходит как { errors: string[] } (см. + * VacancyReportService.GetAsync — например «конец периода раньше начала»); + * остальные статусы — обычный текст/пусто. + */ +async function extractErrorMessage(response: HttpClientResponse): Promise { + const text = await response.text().catch(() => ''); + try { + const parsed: { errors?: string[] } = JSON.parse(text); + if (parsed.errors && parsed.errors.length > 0) return parsed.errors.join('; '); + } catch { + // Не JSON — используем как есть. + } + return text || `${response.status} ${response.statusText}`; +} + +/** Отдать Blob пользователю как скачивание файла — через временную ссылку. */ +function saveBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // Отложенный revoke — на некоторых браузерах немедленный обрывает скачивание, + // начатое кликом синхронно перед этим. + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/src/webparts/requestPortal/shared/reports/reportsAccess.ts b/src/webparts/requestPortal/shared/reports/reportsAccess.ts new file mode 100644 index 0000000..5441c1d --- /dev/null +++ b/src/webparts/requestPortal/shared/reports/reportsAccess.ts @@ -0,0 +1,33 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { effectiveUserEmail } from '../impersonation'; + +/** + * Кто видит раздел «Отчёты» в боковом меню. Пока это фиксированный список + * почт — группы HR в SharePoint ещё нет (её заведут отдельно); появится — + * список ниже заменится на реальную проверку членства (см. TODO). + * + * Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из + * Title сайта: у части учёток встречаются устаревшие site user записи со + * старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас + * рядом с текущим trishina.mariya@tnsu.kz). + */ +const ALLOWED_EMAILS: ReadonlySet = new Set([ + 'trishina.mariya@tnsu.kz', // Мария Тришина + 'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий + 'gorbenko.pavel@tnsu.kz', // Павел Горбенко +]); + +// TODO: заменить точечный список на членство в SharePoint-группе HR, когда +// её заведут — тогда список выше станет фолбэком для тех троих, кто должен +// видеть раздел независимо от состава группы (администраторы портала). + +/** + * Есть ли у текущего пользователя доступ к разделу «Отчёты». Использует + * effectiveUserEmail — то есть учитывает отладочную подмену личности (см. + * impersonation.ts): под подменённым пользователем раздел виден/не виден + * так же, как было бы у него самого. + */ +export function hasReportsAccess(context: WebPartContext): boolean { + const email = effectiveUserEmail(context).trim().toLowerCase(); + return email.length > 0 && ALLOWED_EMAILS.has(email); +} diff --git a/src/webparts/requestPortal/shared/routing.ts b/src/webparts/requestPortal/shared/routing.ts index bba1ac1..9637af7 100644 --- a/src/webparts/requestPortal/shared/routing.ts +++ b/src/webparts/requestPortal/shared/routing.ts @@ -35,7 +35,7 @@ export type PortalMode = 'submit' | 'approve' | 'create' | 'edit'; * «Возвращена на доработку»); в отличие от первых двух смотрит * на список данных по автору, а не на строки маршрута. */ -export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search'; +export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports'; /** * Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор @@ -259,6 +259,7 @@ function readStoredNav(): NavSection { raw === 'rework' || raw === 'own' || raw === 'search' || + raw === 'reports' || raw === 'new' ? raw : 'new'; @@ -283,6 +284,7 @@ function parseNav(raw: string | undefined): NavSection { raw === 'rework' || raw === 'own' || raw === 'search' || + raw === 'reports' || raw === 'new' ) { return raw; -- 2.34.1 From f7979ac5342b8e454294d6cba4a2b7047e7b5600 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 14:10:36 +0500 Subject: [PATCH 2/5] =?UTF-8?q?docs(tools/sp):=20=D0=B3=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=20=D0=BD=D0=B0=20=D1=8D=D0=BB?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверено на тенанте 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 --- tools/sp/README.md | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/tools/sp/README.md b/tools/sp/README.md index 6e24608..a9148c1 100644 --- a/tools/sp/README.md +++ b/tools/sp/README.md @@ -13,10 +13,14 @@ пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у себя. Ни пароля, ни 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,32 @@ claims-логине частей будет четыре. Разбирать с партиций и в 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` по несуществующему свойству падает ошибкой +400/500, по существующему — работает. + +При этом рядом в тех же списках нормально отдаются `PositionOWSTEXT`, +`RequestStatusOWSCHCS`, `SelectionRequestIdOWSNMBR`, `SelectionRequestListIdOWSTEXT`, +`SubmitDateOWSDATE`, `TansuSiteApproverOWSUSER` — то есть это не про тип колонки и не +про задержку crawl (у свежесозданных элементов рабочие поля появляются, а эти нет). +Из-за этого отчёт по вакансиям в `tnsu-approval-route` остался на items REST по +партициям, а не на Search — подробный расчёт в doc-комментарии `VacancyReportService`. + +**Права разорваны на КАЖДОМ элементе.** У заявок портала +`HasUniqueRoleAssignments = true` поштучно: доступ выдан только участникам заявки плюс +общему админу. Поэтому под своей учёткой в списке видно 2 элемента из 19 — и это не +баг и не кэш браузера, а модель доступа. App-only токен (сертификат) это не трогает и +читает всё. Если сравниваете «сколько элементов в списке», обязательно фиксируйте, под +кем смотрите: `ItemCount` в метаданных списка считает ВСЁ, а `/items` — только +доступное вам. -- 2.34.1 From 0701736e2350e3c6ec77a111ec57b40596a95d64 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 14:22:48 +0500 Subject: [PATCH 3/5] =?UTF-8?q?docs(tools/sp):=20=D0=BA=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D0=B5=D0=B2=D0=B0=D1=8F=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=D0=BF=D1=80=D0=BE=D0=B8=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20+=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=20SortList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина найдена: одноимённые колонки в разных списках — разные поля со своими 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 --- tools/sp/README.md | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tools/sp/README.md b/tools/sp/README.md index a9148c1..0277213 100644 --- a/tools/sp/README.md +++ b/tools/sp/README.md @@ -112,14 +112,33 @@ claims-логине частей будет четыре. Разбирать с Отличить «managed property нет вовсе» от «есть, но у строк пусто» через `SelectProperties` НЕЛЬЗЯ — на несуществующее имя ответ молча приходит с `null`. -Отличается сортировкой: `SortList` по несуществующему свойству падает ошибкой -400/500, по существующему — работает. +**И `SortList` для этого не годится тоже**, хотя соблазнительно: `Sortable` — флаг, +независимый от `Retrievable`, и у автосозданных `ows_*` свойств он обычно выключен. +`SortList` падает 400/500 и на заведомо живом `PositionOWSTEXT`, то есть меряет не то. +Рабочий способ — сверять Search с REST на одних и тех же элементах, это делает +`sp-write props` (репозиторий `C:\work\git\tools`). -При этом рядом в тех же списках нормально отдаются `PositionOWSTEXT`, -`RequestStatusOWSCHCS`, `SelectionRequestIdOWSNMBR`, `SelectionRequestListIdOWSTEXT`, -`SubmitDateOWSDATE`, `TansuSiteApproverOWSUSER` — то есть это не про тип колонки и не -про задержку crawl (у свежесозданных элементов рабочие поля появляются, а эти нет). -Из-за этого отчёт по вакансиям в `tnsu-approval-route` остался на items REST по +**Корневая причина (нашлась 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`. **Права разорваны на КАЖДОМ элементе.** У заявок портала -- 2.34.1 From bd7d83b9ffd5e2ef3ad91dcca3605740179e1ec0 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 28 Aug 2026 16:18:48 +0500 Subject: [PATCH 4/5] =?UTF-8?q?refactor(RecruitingSelection):=20OpenReason?= =?UTF-8?q?=20->=20VacancyOpenReason=20(=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BB=D0=B8=D0=B7=D0=B8=D0=B8=20Search)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Колонка 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 --- .../forms/RecruitingSelection/model.ts | 17 +++++++++++----- .../RecruitingSelection/provisioning.json | 4 ++-- .../sections/ApplicationSection.tsx | 20 +++++++++---------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts index 36c1d24..61ca556 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/model.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/model.ts @@ -18,8 +18,15 @@ export interface IFormData { Responsible: string; SubmitDate: 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[]; ObjectProject: string; ObjectProjectId: string; @@ -92,8 +99,8 @@ export const EMPTY_DATA: IFormData = { Responsible: '', SubmitDate: '', ClosingDeadline: '', - OpenReason: '', - OpenReasonDetails: '', + VacancyOpenReason: '', + VacancyOpenReasonDetails: '', Companies: [], ObjectProject: '', ObjectProjectId: '', @@ -149,7 +156,7 @@ export const OPEN_REASON_REPLACE = 'Замена уволенного сотру export const OPEN_REASON_TRANSFER = 'Перевод сотрудника'; export const OPEN_REASON_NEW = 'Новая штатная единица'; -/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */ +/** Варианты «Причина открытия вакансии» — совпадают с choices колонки VacancyOpenReason. */ export const OPEN_REASON_OPTIONS: ISelectOption[] = [ { value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE }, { value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json index 78cf996..c575ce6 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json +++ b/src/webparts/requestPortal/forms/RecruitingSelection/provisioning.json @@ -5,8 +5,8 @@ { "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] }, { "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true }, - { "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] }, - { "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" }, + { "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] }, + { "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" }, { "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" }, { "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" }, { "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" }, diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx index d540745..c0fa2cf 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/sections/ApplicationSection.tsx @@ -11,7 +11,7 @@ import { } from '../model'; export const ApplicationSection: React.FC = ({ data, set, context, onResponsibleChange }) => { - const reason = data.OpenReason; + const reason = data.VacancyOpenReason; const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW; const reasonDetailsLabel = reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину'; @@ -45,24 +45,24 @@ export const ApplicationSection: React.FC = ({ data, set, context onChange={(v) => set('ClosingDeadline', v)} /> - +