From 364835eaf77f9e245357d7f81091026d9f6db213 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 17 Aug 2026 05:43:38 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D1=85=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BB=D0=B8=D1=81=D1=82=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gorbenko.pavel Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/7 --- docs/forms.md | 15 + .../requestPortal/RequestPortalWebPart.ts | 12 + .../components/RequestPortal.module.scss | 16 +- .../components/RequestPortal.tsx | 15 + src/webparts/requestPortal/data/requests.ts | 1 + .../features/approve/ApproveFeature.tsx | 2 +- .../features/myRequests/myRequests.service.ts | 414 ++++++++++++------ .../features/myRequests/searchTrace.ts | 167 +++++++ .../forms/Clearance/ClearanceForm.module.scss | 20 + .../forms/Clearance/ClearanceForm.tsx | 236 ++++++++++ .../forms/Clearance/ClearanceView.module.scss | 46 ++ .../forms/Clearance/ClearanceView.tsx | 223 ++++++++++ .../requestPortal/forms/Clearance/README.md | 48 ++ .../forms/Clearance/approverRights.ts | 53 +++ .../requestPortal/forms/Clearance/load.ts | 56 +++ .../requestPortal/forms/Clearance/model.ts | 49 +++ .../Clearance/sections/RequestSection.tsx | 88 ++++ .../requestPortal/forms/Clearance/submit.ts | 112 +++++ .../RecruitingHire/RecruitingHireForm.tsx | 46 +- .../RecruitingHire/RecruitingHireView.tsx | 30 +- .../forms/RecruitingHire/submit.ts | 6 +- .../RecruitingSelectionForm.tsx | 43 +- .../RecruitingSelectionView.tsx | 18 +- .../forms/RecruitingSelection/submit.ts | 5 +- .../forms/Ticket1C/Ticket1CForm.tsx | 36 +- .../forms/Ticket1C/Ticket1CView.tsx | 30 +- .../requestPortal/forms/Ticket1C/submit.ts | 9 +- .../forms/TmcTransfer/TmcTransferForm.tsx | 55 ++- .../forms/TmcTransfer/TmcTransferView.tsx | 36 +- .../requestPortal/forms/TmcTransfer/submit.ts | 6 +- .../forms/Vacation/VacationForm.tsx | 38 +- .../forms/Vacation/VacationView.tsx | 21 +- .../requestPortal/forms/Vacation/submit.ts | 8 +- .../WorkplaceSetup/WorkplaceSetupForm.tsx | 46 +- .../WorkplaceSetup/WorkplaceSetupView.tsx | 39 +- .../forms/WorkplaceSetup/submit.ts | 5 +- src/webparts/requestPortal/forms/registry.ts | 3 + .../requestPortal/forms/viewRegistry.ts | 10 + src/webparts/requestPortal/loc/en-us.js | 2 + src/webparts/requestPortal/loc/mystrings.d.ts | 2 + .../shared/approval/approvalList.service.ts | 88 +++- .../shared/approval/approvalRoute.service.ts | 37 ++ .../requestPortal/shared/approval/index.ts | 4 +- .../shared/approval/models/IApprovalRow.ts | 4 + .../shared/approval/notify.service.ts | 36 ++ .../shared/approval/useRequestTypeId.ts | 46 ++ .../requestPortal/shared/impersonation.ts | 39 ++ 47 files changed, 1962 insertions(+), 359 deletions(-) create mode 100644 src/webparts/requestPortal/features/myRequests/searchTrace.ts create mode 100644 src/webparts/requestPortal/forms/Clearance/ClearanceForm.module.scss create mode 100644 src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx create mode 100644 src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss create mode 100644 src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx create mode 100644 src/webparts/requestPortal/forms/Clearance/README.md create mode 100644 src/webparts/requestPortal/forms/Clearance/approverRights.ts create mode 100644 src/webparts/requestPortal/forms/Clearance/load.ts create mode 100644 src/webparts/requestPortal/forms/Clearance/model.ts create mode 100644 src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx create mode 100644 src/webparts/requestPortal/forms/Clearance/submit.ts create mode 100644 src/webparts/requestPortal/shared/approval/useRequestTypeId.ts create mode 100644 src/webparts/requestPortal/shared/impersonation.ts diff --git a/docs/forms.md b/docs/forms.md index 638e9f0..9a11499 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -11,6 +11,20 @@ в `forms/viewRegistry.ts`. 4. Плитка в `data/requests.ts`. +## Формы больше не хардкодят RequestTypeId +Числовой id типа заявки во внешнем approval-route API (`GET /api/request-types`) +выдаётся сервером при регистрации типа в админке и непредсказуем заранее (в +старой pto-tracker у «отпуска» было 1, при заведении типа в новой админке он +получил id 11). Раньше каждая форма хардкодила это число константой вида +`VACATION_REQUEST_TYPE_ID = 11`; теперь — резолвит его в рантайме по своему +`pageUrl` (тот же rptype, что и `id` в `data/requests.ts`) через +`useRequestTypeId(context, pageUrl)` (`shared/approval`). Результат кешируется +на уровне модуля на время сессии (см. `getRequestTypeIdByPageUrl` в +`approvalRoute.service.ts`) — повторный вызов из другой формы не бьёт по сети заново. +- Форма подачи знает свой `pageUrl` из пропа `requestType.id`. +- Режим согласующего (`<Форма>View`) получает его пропом `formKey` (см. + `IApprovalViewProps` в `forms/viewRegistry.ts` и `ApproveFeature.tsx`). + ## Что НЕ дублируем в карточке (лежит в других местах) - Колонки списков → `forms/<Форма>/provisioning.json`. - Параметры ссылок (rptype/rpmode/rpid/rpsource) → [`routing.md`](./routing.md). @@ -30,6 +44,7 @@ | [`recruiting-hire`](../src/webparts/requestPortal/forms/RecruitingHire/README.md) | Заявка на прием | Рекрутинг | да | да | да | маска телефона, деньги, условное «взамен кого», источник для орг. места | ✅ | | [`it-workplace-setup`](../src/webparts/requestPortal/forms/WorkplaceSetup/README.md) | Организация рабочего места | IT | да | да | да | префилл из «прием», поля ПО/доступы правит руководитель | ✅ | | [`it-1c-ticket`](../src/webparts/requestPortal/forms/Ticket1C/README.md) | Заявка в отдел 1С | IT | да | нет | да | контакты предзаполняются из Graph (/me); вместо фазы исполнения — карточка в Microsoft Planner (согласовано/сразу без согласования — рабочий бакет, отклонено — отдельный бакет) | ✅ | +| [`clearance-form`](../src/webparts/requestPortal/forms/Clearance/README.md) | Обходной лист | Отдел кадров | да | нет | да | подаётся HR за увольняемого (People-picker); условие «IsManager» резолвится по должности из самой заявки; да/нет-поля (парковка, СКУД), сумма обучения | ✅ | | `hr-transfer` | Перемещение сотрудников | Отдел кадров | — | — | — | — | ⏳ заглушка (плитка есть, форма не сделана) | Легенда: **Согл.** — есть фаза согласования; **Исп.** — есть фаза исполнения; diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 3ee3ac1..34789f4 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -13,10 +13,13 @@ import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; import { installErrorLogging } from './shared/diagnostics'; +import { setImpersonatedEmail } from './shared/impersonation'; import { ErrorOverlay } from './shared/ui'; export interface IRequestPortalWebPartProps { description: string; + /** Отладочная подмена личности — см. shared/impersonation.ts. На бою пусто. */ + debugUserEmail: string; } export default class RequestPortalWebPart extends BaseClientSideWebPart { @@ -25,6 +28,10 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart/.module.scss. // Здесь — только оболочка (root + раскладка колонок), общая для всех фич. diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 35b3144..19621f1 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -20,6 +20,7 @@ import { OwnTab, } from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; +import { getImpersonatedEmail } from '../shared/impersonation'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { Drawer } from './Drawer/Drawer'; import { CategoryTabs } from './CategoryTabs/CategoryTabs'; @@ -57,6 +58,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context .filter(Boolean) .join(' '); + // Читаем при рендере, а не через состояние: значение выставляет веб-часть + // (setImpersonatedEmail в render) до создания этого элемента, и меняется оно + // только вместе с новым render всей веб-части. + const impersonatedEmail = getImpersonatedEmail(); + const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); const [mobileNavOpen, setMobileNavOpen] = React.useState(false); @@ -265,6 +271,15 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context
+ {/* Подмену личности легко забыть включённой — тогда портал молча показывал + бы чужие заявки и чужую очередь. Полосу поэтому не прячем и не даём + закрыть: она исчезает только когда свойство веб-части очищено. */} + {impersonatedEmail && ( +
+ Включена отладочная подмена: портал работает от имени {impersonatedEmail}. + Очистите поле в свойствах веб-части, чтобы вернуться к своей учётной записи. +
+ )}
= ({ context, formKey, item
{ViewComponent ? ( Загрузка…
}> - + ) : (
Для формы «{formKey}» режим согласования пока не настроен.
diff --git a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts index d6967b3..344b76b 100644 --- a/src/webparts/requestPortal/features/myRequests/myRequests.service.ts +++ b/src/webparts/requestPortal/features/myRequests/myRequests.service.ts @@ -1,3 +1,4 @@ +import { SPFI } from '@pnp/sp'; import { spForSite } from '../../shared/pnp/pnpjs'; import '@pnp/sp/webs'; import '@pnp/sp/lists'; @@ -7,8 +8,10 @@ import '@pnp/sp/site-users/web'; import '@pnp/sp/search'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getRequestTypes, IRequestType, REQUEST_STATUS } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IMyRequest } from './models/IMyRequest'; import { IMyRequestsTypeDebug } from './models/IMyRequestsDebug'; +import { traceLinkedTypes, traceResultRows, traceSearchHits } from './searchTrace'; // Список данных «... — данные» → список согласования «... — согласование» // (конвенция именования; см. forms/*/provisioning.json и forms/*/approval/provisioning.json). @@ -22,11 +25,24 @@ const DATA_LIST_SUFFIX = /\s*—\s*данные\s*$/; // свежесть); при поиске по тексту эта подстраховка не применяется (см. RequestsTableView). const RECENT_MS = 10 * 60 * 1000; -// Сколько строк согласования максимум забираем из Search за один запрос -// (см. getInvolvedRowsViaSearch). Постраничного перебора здесь нет: набор -// целиком уходит в слияние и сортировку по дате заявки, поэтому при превышении -// лимита пишем предупреждение в лог, а не молча обрезаем. -const SEARCH_ROW_LIMIT = 500; +// Сколько строк максимум запрашиваем у Search за ОДИН HTTP-вызов — верхняя +// граница RowLimit самого SharePoint Search REST API. Полный набор всегда +// собирается несколькими такими вызовами через StartRow (см. +// fetchAllSearchRows) — это не потолок итогового результата. +const SEARCH_PAGE_SIZE = 500; + +// Абсолютный потолок на суммарное число строк, которые fetchAllSearchRows +// заберёт за все страницы, — защита от зависания на аномально большом +// индексе, а не ожидаемый рабочий предел. При достижении пишем предупреждение +// и возвращаем то, что успели собрать, вместо бесконечного перебора. +const SEARCH_HARD_CAP = 5000; + +// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`) +// при дочитывании полей по найденным через Search элементам (см. loadByIds). +// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют +// упереться в лимит, поэтому набор Id одного типа режется на такие пачки, +// а не уходит одним запросом целиком. +const REST_ID_FILTER_CHUNK = 100; // Сколько последних заявок автора дочитываем через REST в довесок к Search // в разделе «Мои заявки» (см. getFreshOwnRowsForType). Это записи ОДНОГО @@ -41,26 +57,35 @@ const MP_APPROVER = 'TansuSiteApproverOWSUSER'; // Статус СТРОКИ МАРШРУТА (колонка ApprovalStatus списка «— согласование») — // Choice-поле, отсюда суффикс OWSCHCS (подтверждено отладочным перебором // 2026-08-06; у Person-полей он OWSUSER, см. MP_APPROVER). Колонку не путать с -// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком. -// -// Для статуса ЗАЯВКИ (список ДАННЫХ) отдельной managed property здесь -// НАМЕРЕННО нет: имя для произвольной Choice-колонки в схеме поиска либо надо -// знать точно, либо угадывать по конвенции — угадали неверно (или колонка -// после переименования Status → RequestStatus ещё не переиндексирована), и -// фильтр по статусу молча возвращал 0 заявок. Поэтому статус заявки -// фильтруется НЕ в KQL, а в памяти после дочитывания реальных полей через REST -// (см. getOwnRequestsByStatus) — Search там ищет только по надёжному -// AuthorOWSUSER, статус ни при чём. +// RequestStatus списка ДАННЫХ — это статус ЗАЯВКИ целиком (см. MP_REQUEST_STATUS +// ниже). const MP_STATUS = 'ApprovalStatusOWSCHCS'; +// Статус ЗАЯВКИ (колонка RequestStatus списка ДАННЫХ) — Choice-поле, тот же +// суффикс OWSCHCS, без префикса группы колонок сайта (поле локальное для +// списка, как и ApprovalStatus выше). Раньше здесь стояло «угадали неверно, +// фильтр молча возвращает 0» — перепроверено вручную через +// /_api/search/query 2026-08-17: и голый запрос по этому свойству, и в +// связке с Path, отдают счётчики, совпадающие с REST-эталоном (см. +// getOwnRequestsByStatus). Старый вывод о поломке был артефактом кривого +// тестового Path (задвоенный сегмент /sites/…), а не реальным ограничением +// SharePoint — сама схема поиска исправна. +const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS'; + +// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности +// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны +// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой +// маршрут в форме, но собственные списки заявок. export function currentUserEmail(context: WebPartContext): string { - return (context.pageContext.user.email || '').toLowerCase(); + return effectiveUserEmail(context).toLowerCase(); } // Типы заявок, у которых approval-route API уже отдал listId/siteUrl (готовы к чтению). export async function getLinkedTypes(context: WebPartContext): Promise { const types = await getRequestTypes(context); - return types.filter((t) => !!t.listId && !!t.siteUrl); + const linked = types.filter((t) => !!t.listId && !!t.siteUrl); + traceLinkedTypes(types, linked); + return linked; } // Что показывать в разделе «На согласовании»: @@ -269,12 +294,12 @@ async function indexApprovalsForType( // SharePoint REST — резолвим email в числовой Id пользователя (как и везде в // проекте, см. ensureUser) и фильтруем по TansuSiteApproverId. // - // ApprovalStatus в $filter НЕ используем: подтверждено на практике — фильтр - // вида `ApprovalStatus eq 'На согласовании'` (кириллица в $filter) молча - // возвращает 0 строк, даже когда без него те же строки находятся с этим же - // значением статуса. Поэтому фильтруем только по TansuSiteApproverId - // (числовое значение, проблем нет), а статус сравниваем в JS после - // получения строк. + // ApprovalStatus в $filter теперь ИСПОЛЬЗУЕМ: раньше здесь считалось, что + // кириллица в значении делает такой фильтр ненадёжным, — перепроверено + // вручную через REST 2026-08-17 (все три статуса списка согласования дали + // точное совпадение с эталоном), теория не подтвердилась. Старый вывод о + // поломке был артефактом кривого тестового запроса, а не реальным + // ограничением SharePoint. const claimsLogin = tokenEmail.indexOf('|') >= 0 ? tokenEmail : `i:0#.f|membership|${tokenEmail}`; const approver = await sp.web.ensureUser(claimsLogin); @@ -289,20 +314,20 @@ async function indexApprovalsForType( // getInvolvedRowsViaSearch, поэтому решение «моя очередь сейчас» всегда // точное, даже когда список кандидатов пришёл из Search. const cutoffIso = new Date(Date.now() - RECENT_MS).toISOString(); + // Статус — прямо в $filter (см. комментарий про ApprovalStatus выше): + // REST-довесок сразу отдаёт только нужные строки, без постфильтрации в JS. + const statusFilter = + statuses.length > 0 ? ` and (${statuses.map((s) => `ApprovalStatus eq '${s}'`).join(' or ')})` : ''; const recentRows = (await approvalList.items - .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'`) + .filter(`TansuSiteApproverId eq ${approver.Id} and Modified ge '${cutoffIso}'${statusFilter}`) .select('Id', 'TicketId', 'ApprovalStatus') .top(2000)()) as Array>; - // Статусы уходят в KQL: сервер сразу отдаёт только нужные строки, + // Статусы уходят и в KQL: сервер сразу отдаёт только нужные строки, // а не всё, где пользователь есть в маршруте. const searchRows = await getInvolvedRowsViaSearch(context, type, approvalList, statuses); const seenIds = new Set(recentRows.map((r) => r.Id)); - const allRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); - - // REST-довесок берётся по Modified без учёта статуса (кириллица в $filter - // ненадёжна, см. выше) — отбор по статусу делаем здесь, на клиенте. - stepRows = allRows.filter((r) => statuses.indexOf(r.ApprovalStatus as string) !== -1); + stepRows = recentRows.concat(searchRows.filter((r) => !seenIds.has(r.Id))); } else { // 'involved' — используется ТОЛЬКО для текстового поиска (см. // getPendingApprovalIndex/searchMyRequests) — здесь важна не мгновенная @@ -401,7 +426,9 @@ async function getInvolvedRowsViaSearch( .expand('RootFolder')()) as { RootFolder?: { ServerRelativeUrl?: string } }; const relUrl = rootFolder.RootFolder?.ServerRelativeUrl; if (!relUrl) return []; - const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl}`; + // Слеш на конце обязателен: без него префикс …/Lists/List1 совпадает и с + // путями элементов …/Lists/List10 (см. listPathPrefix). + const approvalListAbsUrl = `${siteOrigin(type.siteUrl as string)}${relUrl.replace(/\/+$/, '')}/`; const sp = spForSite(type.siteUrl as string, context); const pathClause = `Path:"${approvalListAbsUrl}*"`; @@ -411,29 +438,24 @@ async function getInvolvedRowsViaSearch( } const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: SEARCH_ROW_LIMIT, - SelectProperties: ['ListItemID'], - TrimDuplicates: false, - }); - - const total = results.TotalRows || 0; - const ids = (results.PrimarySearchResults as Array>) + const hits = await fetchAllSearchRows(sp, kql, ['ListItemID'], `«${type.name}»`); + const ids = hits .map((r) => Number(r.ListItemID)) .filter((id) => !isNaN(id) && id > 0); - if (total > SEARCH_ROW_LIMIT) { - console.warn( - `[Текущие заявки] «${type.name}»: Search вернул ${total} строк, взяли первые ${SEARCH_ROW_LIMIT} — остальные не попадут в выборку` - ); - } if (ids.length === 0) return []; - return (await approvalList.items - .filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'TicketId', 'ApprovalStatus') - .top(ids.length)()) as Array>; + // Id режем на пачки — тот же предел длины URL REST $filter, что и в + // loadByIds (см. REST_ID_FILTER_CHUNK). + const chunks = await Promise.all( + chunk(ids, REST_ID_FILTER_CHUNK).map((idsChunk) => + approvalList.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'TicketId', 'ApprovalStatus') + .top(idsChunk.length)() + ) + ); + return (chunks as Array>>).reduce((all, r) => all.concat(r), []); } catch (e) { console.error( `[Текущие заявки] «${type.name}»: Search по списку согласования не сработал:`, @@ -508,10 +530,64 @@ export interface IOwnRequestsPage { } const PAGE_SIZE = 20; -// Сколько заявок максимум перечисляем в KQL при поиске среди уже отобранных -// (см. filterPendingByQuery) — иначе строка запроса станет неподъёмной. +// Сколько заявок-кандидатов максимум перечисляем в ОДНОМ KQL-запросе при +// поиске среди уже отобранных (см. filterPendingByQuery) — не потолок +// итогового результата, а размер чанка: кандидатов режем на такие пачки и +// прогоняем каждую отдельным запросом, чтобы строка запроса не разрослась +// до неподъёмной, но при этом ни один кандидат не терялся. const KQL_ID_LIMIT = 300; +function chunk(items: T[], size: number): T[][] { + const result: T[][] = []; + for (let i = 0; i < items.length; i += size) { + result.push(items.slice(i, i + size)); + } + return result; +} + +// Собрать ВЕСЬ результат Search по запросу, а не одну страницу: сам +// SharePoint Search REST API ограничивает RowLimit одного вызова (см. +// SEARCH_PAGE_SIZE), поэтому при большом совпадающем наборе перебираем +// StartRow, пока индекс не перестанет отдавать новые строки или пока не +// упрёмся в SEARCH_HARD_CAP (аварийный потолок, а не ожидаемый лимит — при +// его достижении пишем предупреждение и отдаём то, что успели собрать). +async function fetchAllSearchRows( + sp: SPFI, + kql: string, + selectProperties: string[], + label: string, + sortList?: Array<{ Property: string; Direction: number }> +): Promise>> { + const rows: Array> = []; + let startRow = 0; + + for (;;) { + const results = await sp.search({ + Querytext: kql, + RowLimit: SEARCH_PAGE_SIZE, + StartRow: startRow, + SelectProperties: selectProperties, + TrimDuplicates: false, + ...(sortList ? { SortList: sortList } : {}), + }); + + const page = results.PrimarySearchResults as Array>; + rows.push(...page); + const total = results.TotalRows || 0; + startRow += page.length; + + if (page.length === 0 || startRow >= total) break; + if (rows.length >= SEARCH_HARD_CAP) { + console.warn( + `[Текущие заявки] ${label}: индекс отдал ${total} строк — забрали только первые ${rows.length} (аварийный потолок)` + ); + break; + } + } + + return rows; +} + function siteOrigin(siteUrl: string): string { const match = /^https?:\/\/[^/]+/.exec(siteUrl); return match ? match[0] : siteUrl; @@ -521,6 +597,24 @@ function listAbsUrl(type: IRequestType): string { return `${siteOrigin(type.siteUrl as string)}${type.listId}`; } +// Префикс пути для KQL-условия Path и для сопоставления хитов с типом. +// +// Обязательно СО слешем на конце: адреса списков в этом сайте отличаются только +// номером (…/Lists/List1, …/Lists/List10), и «голый» префикс List1 совпадает с +// путями элементов List10. В KQL это притягивало чужие элементы, а при разборе +// ответа хит уезжал в первый подошедший тип — заявки «Обходного листа» +// приписывались списку List1, дальше их Id искались не в том списке и строки +// молча исчезали из выдачи. +function listPathPrefix(type: IRequestType): string { + return `${listAbsUrl(type).replace(/\/+$/, '')}/`; +} + +// Хит принадлежит типу, только если путь элемента лежит ВНУТРИ списка — +// сравнение по границе сегмента, не по «голому» началу строки. +export function pathBelongsToList(path: string, type: IRequestType): boolean { + return path.toLowerCase().indexOf(listPathPrefix(type).toLowerCase()) === 0; +} + // KQL не терпит спецсимволов в свободном терме — оставляем буквы (лат./кир.), // цифры и пробелы. (без \p{L} — целевой ES-таргет проекта его не поддерживает) function sanitizeForKql(text: string): string { @@ -542,23 +636,26 @@ function freeTextClause(query: string): string | undefined { // // Дедуп по (тип, Id) — ЗДЕСЬ, а не у вызывающей стороны: во всех запросах // стоит TrimDuplicates: false (нужно, чтобы найти черновики и «свои» копии — -// стандартная дедупликация Search иногда схлопывает и их), а без неё индекс -// может вернуть один и тот же элемент несколькими хитами (переиндексация, -// несколько «версий» в crawl). Раньше хиты превращались в строки один-в-один, -// и дубликат из индекса становился дубликатом строки в таблице — с одинаковым -// React-ключом, то есть видимым «залипанием» одной и той же заявки дважды. +// стандартная дедупликация Search схлопывает по содержимому, а не по Id, и +// вместе с ними может случайно схлопнуть две по-настоящему разные, но похожие +// заявки), а без неё индекс может вернуть один и тот же элемент несколькими +// хитами (переиндексация, несколько «версий» в crawl). Раньше хиты +// превращались в строки один-в-один, и дубликат из индекса становился +// дубликатом строки в таблице — с одинаковым React-ключом, то есть видимым +// «залипанием» одной и той же заявки дважды. function hitsToEntries( results: { PrimarySearchResults: unknown[] }, types: IRequestType[] ): Array<{ id: number; type: IRequestType }> { - const urls = types.map((t) => ({ type: t, url: listAbsUrl(t).toLowerCase() })); const seen = new Set(); return (results.PrimarySearchResults as Array>) - .map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '').toLowerCase() })) + .map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') })) .filter((h) => !isNaN(h.id) && h.id > 0) .map((h) => { - const matched = urls.filter((u) => h.path.indexOf(u.url) === 0)[0]; - return matched ? { id: h.id, type: matched.type } : undefined; + // Сравнение по границе сегмента (см. listPathPrefix): при «голом» + // indexOf хиты List10 доставались типу List1 — тому, что раньше в массиве. + const matched = types.filter((t) => pathBelongsToList(h.path, t))[0]; + return matched ? { id: h.id, type: matched } : undefined; }) .filter((e): e is { id: number; type: IRequestType } => !!e) .filter((e) => { @@ -571,6 +668,11 @@ function hitsToEntries( // Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для // кастомных полей нет гарантии, что они проиндексированы как managed property). +// +// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search +// собирает результат целиком (см. fetchAllSearchRows), их может набраться +// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в +// предел длины URL SharePoint REST. async function loadByIds( context: WebPartContext, entries: Array<{ id: number; type: IRequestType }>, @@ -587,16 +689,21 @@ async function loadByIds( await Promise.all( Array.from(idsByType.entries()).map(async ([type, ids]) => { const sp = spForSite(type.siteUrl as string, context); - const rows = (await sp.web - .getList(type.listId as string) - .items.filter(ids.map((id) => `Id eq ${id}`).join(' or ')) - .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') - .expand('Author') - .top(ids.length)()) as Array>; - rows.forEach((r) => { - const author = r.Author as { EMail?: string; Title?: string } | undefined; - byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind)); - }); + const list = sp.web.getList(type.listId as string); + + await Promise.all( + chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => { + const rows = (await list.items + .filter(idsChunk.map((id) => `Id eq ${id}`).join(' or ')) + .select('Id', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title') + .expand('Author') + .top(idsChunk.length)()) as Array>; + rows.forEach((r) => { + const author = r.Author as { EMail?: string; Title?: string } | undefined; + byKey.set(`${type.id}-${r.Id as number}`, toMyRequest(r, author, type, kind)); + }); + }) + ); }) ); return byKey; @@ -604,9 +711,8 @@ async function loadByIds( // Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход // Search — сырьё для довеска по свежести (см. getFreshOwnRowsForType). -// Фильтруем только по AuthorId: статус в $filter не используем, потому что -// кириллица в сравнении там уже подводила (см. indexApprovalsForType) — отбор -// по статусу делает вызывающая сторона. +// Фильтруем только по AuthorId, без статуса: эта функция возвращает ВСЕ +// заявки автора, отбор по статусу (если он нужен) делает вызывающая сторона. async function getOwnRowsForType(context: WebPartContext, type: IRequestType): Promise { try { const sp = spForSite(type.siteUrl as string, context); @@ -728,7 +834,7 @@ export async function searchOwnRequests( const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; const clauses = [ - `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, `AuthorOWSUSER:"${claimsLogin}"`, ]; const text = freeTextClause(query); @@ -758,17 +864,10 @@ export async function searchOwnRequests( // Свои заявки с конкретным статусом ЗАЯВКИ (не строки маршрута) — «Доработка» // и фильтр по статусу на вкладке «Инициированные». // -// Статус НЕ уходит в KQL: единственная надёжная managed property здесь — -// AuthorOWSUSER (стандартное поле автора), а для произвольной Choice-колонки -// имя managed property надо либо знать из схемы поиска, либо угадывать по -// конвенции — угадали неверно (или она ещё не переиндексирована после -// переименования колонки Status → RequestStatus), из-за чего фильтр по -// статусу молча возвращал 0. Поэтому здесь Search ищет ТОЛЬКО по автору -// (+ опционально текст) — это находит ВСЕ заявки автора вплоть до -// KQL_ID_LIMIT, — а отбор по статусу и постраничность делаются в памяти по -// уже дочитанным через REST реальным полям (см. searchOwnAll/loadByIds). -// Тот же принцип, что и в filterPendingByQuery: Search сужает набор по -// надёжному полю, остальное — на клиенте. +// Статус уходит прямо в KQL через MP_REQUEST_STATUS (проверено вручную +// 2026-08-17, см. комментарий у константы) — это настоящий серверный запрос +// с RowLimit/StartRow, как и searchOwnRequests, а не «стянуть всё и +// отфильтровать в памяти». export async function getOwnRequestsByStatus( context: WebPartContext, types: IRequestType[], @@ -776,28 +875,52 @@ export async function getOwnRequestsByStatus( page: number, query: string = '' ): Promise { - const all = await searchOwnAll(context, types, query); - const searchFiltered = all.filter((r) => r.status === status); + if (types.length === 0) return { rows: [], totalRows: 0 }; + + const sp = spForSite(types[0].siteUrl as string, context); + const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; + const clauses = [ + `(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, + `AuthorOWSUSER:"${claimsLogin}"`, + `${MP_REQUEST_STATUS}:"${status}"`, + ]; + const text = freeTextClause(query); + if (text) clauses.push(text); + const kql = clauses.join(' AND '); + + const results = await sp.search({ + Querytext: kql, + RowLimit: PAGE_SIZE, + StartRow: (page - 1) * PAGE_SIZE, + SelectProperties: ['ListItemID', 'Title', 'Path'], + TrimDuplicates: false, + // Свежие сверху — как в searchOwnRequests. + SortList: [{ Property: 'Created', Direction: 1 }], + }); + + const totalRows = results.TotalRows || 0; + const entries = hitsToEntries(results, types); + const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); + const searchRows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); + + if (page !== 1 || query.trim()) return { rows: searchRows, totalRows }; // Довесок по недавним изменениям — только на первой странице и только без - // текстового поиска (см. getOwnRequests: тот же принцип — довесок относится - // к «показать всё, свежее по дате», а не к результату полнотекстового поиска). - let merged = searchFiltered; - if (page === 1 && !query.trim()) { - const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t)))) - .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) - .filter((r) => r.status === status); + // текстового поиска (тот же принцип, что в getOwnRequests): заявку могли + // вернуть на доработку только что, индекс мог не успеть её подхватить. + const freshRows = (await Promise.all(types.map((t) => getRecentlyChangedOwnRowsForType(context, t)))) + .reduce((acc, r) => acc.concat(r), [] as IMyRequest[]) + .filter((r) => r.status === status); - const seen = new Set(searchFiltered.map((r) => `${r.requestTypeId}-${r.id}`)); - const onlyFresh = freshRows.filter((r) => !seen.has(`${r.requestTypeId}-${r.id}`)); - merged = onlyFresh.concat(searchFiltered); - } + const searchKeys = new Set(searchRows.map((r) => `${r.requestTypeId}-${r.id}`)); + const onlyFresh = freshRows.filter((r) => !searchKeys.has(`${r.requestTypeId}-${r.id}`)); - const sorted = merged.sort((a, b) => b.created.localeCompare(a.created)); - return { - rows: sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), - totalRows: sorted.length, - }; + const merged = onlyFresh + .concat(searchRows) + .sort((a, b) => b.created.localeCompare(a.created)) + .slice(0, PAGE_SIZE); + + return { rows: merged, totalRows: totalRows + onlyFresh.length }; } // Список согласования по типу заявки: Title списка данных → Title списка @@ -860,17 +983,17 @@ export async function hydrateDecisionComments( const approvalList = await approvalListFor(context, type); if (!approvalList) return; - // Фильтруем только по TicketId (числовое поле), статус сравниваем в JS: - // кириллица в $filter по Choice-колонке молча возвращает 0 строк — - // ровно та же причина, что и в indexApprovalsForType. + // TicketId (OR-цепочка) в скобках обязателен: у OData `and` связывает + // сильнее `or», без скобок фильтр разобрался бы как + // «TicketId eq 1 or (TicketId eq 2 and ApprovalStatus eq …)». + const ticketFilter = typeRows.map((r) => `TicketId eq ${r.id}`).join(' or '); const stepRows = (await approvalList.items - .filter(typeRows.map((r) => `TicketId eq ${r.id}`).join(' or ')) - .select('TicketId', 'ApprovalStatus', 'Comment') + .filter(`(${ticketFilter}) and ApprovalStatus eq '${decisionStatus}'`) + .select('TicketId', 'Comment') .top(DECISION_COMMENT_ROW_LIMIT)()) as Array>; const commentByTicket = new Map(); stepRows.forEach((r) => { - if (r.ApprovalStatus !== decisionStatus) return; const ticketId = r.TicketId as number | undefined; const comment = ((r.Comment as string) || '').trim(); // Такая строка по заявке одна (и возврат, и отклонение останавливают @@ -900,6 +1023,11 @@ export async function hydrateDecisionComments( // обычным REST). Здесь среди НИХ фильтруем по тексту — только через Search // (KQL ограничивается ListItemID этих заявок, текст идёт свободным термом по // всем полям), без клиентского REST-матчинга по свежести. +// +// Кандидатов режем на чанки по KQL_ID_LIMIT и гоняем каждый отдельным +// запросом (см. chunk/fetchAllSearchRows) — раньше «лишние» сверх лимита +// кандидаты молча выпадали из поиска, теперь ни один не теряется, просто +// уходит несколькими запросами вместо одного. export async function filterPendingByQuery( context: WebPartContext, types: IRequestType[], @@ -909,34 +1037,35 @@ export async function filterPendingByQuery( const text = freeTextClause(query); if (!text || rows.length === 0) return rows; - if (rows.length > KQL_ID_LIMIT) { - console.warn( - `[Текущие заявки] Поиск среди ждущих решения ограничен ${KQL_ID_LIMIT} заявками из ${rows.length}` - ); - } - const candidates = rows.slice(0, KQL_ID_LIMIT); - - const scopeTypes = types.filter((t) => candidates.some((r) => r.requestTypeId === t.id)); + const scopeTypes = types.filter((t) => rows.some((r) => r.requestTypeId === t.id)); if (scopeTypes.length === 0) return []; const sp = spForSite(scopeTypes[0].siteUrl as string, context); - const kql = [ - `(${scopeTypes.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, - `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, - text, - ].join(' AND '); + const pathClause = `(${scopeTypes.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`; - const results = await sp.search({ - Querytext: kql, - RowLimit: KQL_ID_LIMIT, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - }); + const found = new Set(); + await Promise.all( + chunk(rows, KQL_ID_LIMIT).map(async (candidates) => { + const kql = [ + pathClause, + `(${candidates.map((r) => `ListItemID:${r.id}`).join(' OR ')})`, + text, + ].join(' AND '); + const hits = await fetchAllSearchRows( + sp, + kql, + ['ListItemID', 'Title', 'Path'], + 'Поиск среди ждущих решения' + ); + // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. + hitsToEntries({ PrimarySearchResults: hits }, scopeTypes).forEach((e) => + found.add(`${e.type.id}-${e.id}`) + ); + }) + ); - // ListItemID уникален только внутри своего списка — сверяем пару тип+Id. - const found = new Set(hitsToEntries(results, scopeTypes).map((e) => `${e.type.id}-${e.id}`)); - return candidates.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); + return rows.filter((r) => found.has(`${r.requestTypeId}-${r.id}`)); } // --------------------------------------------------------------------------- @@ -956,8 +1085,10 @@ export interface ISearchPage { totalRows: number; } -// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез (до -// KQL_ID_LIMIT). Только Search, без REST-подстраховки на свежесть. +// Как searchOwnRequests, но без $skip/$top — сразу весь найденный срез +// (см. fetchAllSearchRows — весь, а не первые KQL_ID_LIMIT: тот лимит здесь +// больше не действует, это чанк-размер для другой функции, filterPendingByQuery). +// Только Search, без REST-подстраховки на свежесть. export async function searchOwnAll( context: WebPartContext, types: IRequestType[], @@ -968,24 +1099,25 @@ export async function searchOwnAll( const sp = spForSite(types[0].siteUrl as string, context); const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`; const clauses = [ - `(${types.map((t) => `Path:"${listAbsUrl(t)}*"`).join(' OR ')})`, + `(${types.map((t) => `Path:"${listPathPrefix(t)}*"`).join(' OR ')})`, `AuthorOWSUSER:"${claimsLogin}"`, ]; const text = freeTextClause(query); if (text) clauses.push(text); const kql = clauses.join(' AND '); - const results = await sp.search({ - Querytext: kql, - RowLimit: KQL_ID_LIMIT, - SelectProperties: ['ListItemID', 'Title', 'Path'], - TrimDuplicates: false, - }); + const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки'); + const results = { PrimarySearchResults: hits, TotalRows: hits.length }; + traceSearchHits('Свои заявки', kql, types, results, listAbsUrl); + const entries = hitsToEntries(results, types); const byKey = entries.length > 0 ? await loadByIds(context, entries, 'own') : new Map(); - return entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); + const rows = entries.map((e) => byKey.get(`${e.type.id}-${e.id}`)).filter((r): r is IMyRequest => !!r); + + traceResultRows('Свои заявки', types, entries, rows); + return rows; } /** diff --git a/src/webparts/requestPortal/features/myRequests/searchTrace.ts b/src/webparts/requestPortal/features/myRequests/searchTrace.ts new file mode 100644 index 0000000..3759fcf --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/searchTrace.ts @@ -0,0 +1,167 @@ +import { IRequestType } from '../../shared/approval'; + +/** + * Точечная отладка поиска по ОДНОМУ типу заявки. + * + * Зачем отдельный модуль: тип может выпасть из выдачи на любом из пяти шагов + * (не пришёл из API маршрутов → не попал в KQL → индекс его не отдал → Path + * хита не совпал с адресом списка → REST не вернул строку), и снаружи все пять + * выглядят одинаково — «заявок этого типа нет». Трассировка печатает каждый + * шаг отдельно, чтобы было видно, на каком именно набор становится пустым. + * + * Включение/выключение БЕЗ пересборки — из консоли браузера: + * window.rpSearchTrace = 'обходной' // подстрока имени типа или ключа формы + * window.rpSearchTrace = true // следить за всеми типами + * window.rpSearchTrace = false // выключить + * Значение сверяется с именем типа и с pageUrl (ключом формы), регистр не важен. + */ +const DEFAULT_TRACE: string | boolean = 'обходной'; + +const PREFIX = '[Поиск]'; + +function traceSetting(): string | boolean { + const w = window as unknown as { rpSearchTrace?: string | boolean }; + return w.rpSearchTrace === undefined ? DEFAULT_TRACE : w.rpSearchTrace; +} + +function matches(type: IRequestType, setting: string | boolean): boolean { + if (setting === true) return true; + if (!setting) return false; + const needle = String(setting).toLowerCase(); + return ( + (type.name || '').toLowerCase().indexOf(needle) >= 0 || + (type.pageUrl || '').toLowerCase().indexOf(needle) >= 0 + ); +} + +/** Типы, за которыми сейчас следим. Пусто — трассировка выключена. */ +export function tracedTypes(types: IRequestType[]): IRequestType[] { + const setting = traceSetting(); + if (!setting) return []; + return types.filter((t) => matches(t, setting)); +} + +export function traceEnabled(): boolean { + return !!traceSetting(); +} + +/** + * Шаг 1: что вообще пришло из API маршрутов и что из этого пригодно для чтения. + * + * Тип без listId/siteUrl молча отбрасывается (см. getLinkedTypes) — до поиска + * он не доходит вообще, и это первая версия, которую надо исключить. + */ +export function traceLinkedTypes(all: IRequestType[], linked: IRequestType[]): void { + const setting = traceSetting(); + if (!setting) return; + + const watched = all.filter((t) => matches(t, setting)); + if (watched.length === 0) { + console.warn( + `${PREFIX} Тип по фильтру «${String(setting)}» не найден среди ${all.length} типов из API маршрутов. ` + + `Есть: ${all.map((t) => `${t.name} (${t.pageUrl})`).join(', ')}` + ); + return; + } + + watched.forEach((t) => { + const isLinked = linked.some((l) => l.id === t.id); + console.log(`${PREFIX} Тип «${t.name}» (id=${t.id}, ключ=${t.pageUrl}):`, { + 'дошёл до поиска': isLinked, + listId: t.listId ?? '(нет)', + siteUrl: t.siteUrl ?? '(нет)', + }); + if (!isLinked) { + console.warn( + `${PREFIX} Тип «${t.name}» в поиске НЕ участвует: у него нет listId и/или siteUrl ` + + `в approval-route API — заполните их в админке, иначе портал не знает, где лежит список.` + ); + } + }); +} + +/** + * Шаг 2–4: KQL, сырые хиты индекса и сопоставление их с адресом списка. + * + * Path каждого хита сверяется с абсолютным адресом списка (см. hitsToEntries). + * Если индекс что-то отдал, а совпадений с этим типом нет — печатаем образцы + * путей: расхождение обычно видно глазами (другой сайт, /Lists/ против + * человекочитаемого адреса, иной регистр). + */ +export function traceSearchHits( + label: string, + kql: string, + types: IRequestType[], + results: { PrimarySearchResults: unknown[]; TotalRows?: number }, + listAbsUrl: (type: IRequestType) => string +): void { + const watched = tracedTypes(types); + if (watched.length === 0) return; + + const hits = (results.PrimarySearchResults as Array>).map((r) => + String(r.Path || '').toLowerCase() + ); + + // Со слешем на конце — ровно как в боевом сопоставлении (см. listPathPrefix): + // иначе трассировка показывала бы «хиты есть» там, где код уже отдал их + // другому типу с более коротким адресом (…/Lists/List1 против …/Lists/List10). + const prefix = (t: IRequestType): string => `${listAbsUrl(t).toLowerCase().replace(/\/+$/, '')}/`; + + console.log(`${PREFIX} ${label}: запрос отправлен`, { + KQL: kql, + 'хитов получено': hits.length, + 'TotalRows индекса': results.TotalRows ?? 0, + }); + + watched.forEach((t) => { + const url = prefix(t); + const mine = hits.filter((p) => p.indexOf(url) === 0); + console.log(`${PREFIX} ${label}: «${t.name}» — хитов индекса ${mine.length}`, { + 'адрес списка (ожидаемый префикс Path)': url, + }); + + if (mine.length === 0) { + // Индекс мог не отдать НИЧЕГО (тогда дело не в путях), либо отдать + // чужие хиты (тогда почти наверняка не совпал префикс). + console.warn( + `${PREFIX} ${label}: по типу «${t.name}» индекс не вернул ни одного хита. ` + + (hits.length === 0 + ? 'Хитов нет вообще — проверьте сам запрос и права.' + : `Пути первых чужих хитов (сравните с ожидаемым префиксом выше): ${hits + .slice(0, 5) + .join(' | ')}`) + ); + } + }); +} + +/** + * Шаг 5: сколько строк этого типа дожило до выдачи после сопоставления с + * адресом списка и дочитывания полей через REST. + * + * Расхождение «хиты были, строк нет» означает, что элементы из индекса в + * списке уже не существуют (удалены, индекс отстал) либо REST их не отдал. + */ +export function traceResultRows( + label: string, + types: IRequestType[], + entries: Array<{ type: IRequestType }>, + rows: Array<{ requestTypeId: number }> +): void { + const watched = tracedTypes(types); + if (watched.length === 0) return; + + watched.forEach((t) => { + const mineEntries = entries.filter((e) => e.type.id === t.id).length; + const mineRows = rows.filter((r) => r.requestTypeId === t.id).length; + console.log( + `${PREFIX} ${label}: «${t.name}» — сопоставлено со списком ${mineEntries}, в выдаче ${mineRows}` + ); + if (mineEntries > 0 && mineRows === 0) { + console.warn( + `${PREFIX} ${label}: по типу «${t.name}» элементы в индексе есть, но ни одной строки ` + + `не дочиталось через REST — вероятно, элементы удалены (индекс отстал) или список недоступен.` + ); + } + }); +} diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.module.scss b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.module.scss new file mode 100644 index 0000000..e1ef36d --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.module.scss @@ -0,0 +1,20 @@ +@import '../../../../styles/design-tokens'; + +.back { + display: inline-flex; + align-items: center; + gap: $sp-2; + margin-bottom: $sp-4; + padding: 0; + border: 0; + background: transparent; + color: $navy-700; + font-family: $font-sans; + font-size: $fs-sm; + cursor: pointer; + transition: color .18s; + + &:hover { + color: $gold-600; + } +} diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx new file mode 100644 index 0000000..d0a4ce8 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -0,0 +1,236 @@ +import * as React from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import styles from './ClearanceForm.module.scss'; +import { + Alert, + FormCard, + InitiatorLine, + FormModeProvider, + Icon, + RequestNumber, + SubmitActions, + useRequestSubmit +} from '../../shared/ui'; +import { + ApprovalMatrix, + StatusBadge, + useMatrixEditor, + getPlannedMatrix, + notifyActiveStep, + useRequestTypeId, + REQUEST_STATUS, + isEditableStatus, + IApproverToWrite, + findUnassignedStep +} from '../../shared/approval'; +import { IRequestFormProps } from '../../models/types'; +import { IFormData, EMPTY_DATA, SetField } from './model'; +import { saveClearanceRequest, clearanceApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; +import { loadClearanceRequest } from './load'; +import { RequestSection } from './sections/RequestSection'; + +const ClearanceForm: React.FC = ({ requestType, context, onCancel, onSaved, editId }) => { + const [data, setData] = useState(EMPTY_DATA); + const [requestId, setRequestId] = useState(editId); + const [requestStatus, setRequestStatus] = useState(undefined); + const [requestCode, setRequestCode] = useState(undefined); + const submit = useRequestSubmit(); + + const readOnly = !isEditableStatus(requestStatus); + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + + const approval = useMemo(() => clearanceApprovalService(), []); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; + + // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус. + useEffect(() => { + if (!editId) { + return; + } + let alive = true; + loadClearanceRequest(editId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setRequestStatus(loaded.status); + setRequestCode(loaded.code || undefined); + } + }) + .catch((e) => { + if (alive) { + submit.setError(`Не удалось загрузить заявку №${editId}: ${(e as Error).message}`); + } + }); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editId]); + + // Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай: + // обходной лист оформляют в день увольнения). При открытии на редактирование + // уже сохранённую дату не перезатираем. + useEffect(() => { + if (editId) { + return; + } + const today = new Date().toISOString().slice(0, 10); + setData((prev) => ({ ...prev, TerminationDate: prev.TerminationDate || today })); + }, [editId]); + + // Должность/подразделение больше не вводятся вручную — подтягиваются из + // SharePoint User Profile (SPS-JobTitle/SPS-Department) при выборе сотрудника. + // Если профиль не синхронизирован и поле там пусто — пока просто остаётся + // пустым (TODO: фолбэк на справочник api.tnsu.kz, если это окажется частой + // проблемой на практике). + const handleEmployeeChange = (login: string): void => { + setData((prev) => ({ ...prev, Employee: login, Position: '', Department: '' })); + if (!login) { + return; + } + approval + .getProfileFields(login) + .then((profile) => setData((prev) => ({ ...prev, Position: profile.jobTitle, Department: profile.department }))) + .catch(() => undefined); + }; + + // Планируемый маршрут резолвится по e-mail УВОЛЬНЯЕМОГО (data.Employee, не + // текущего пользователя — форму подаёт HR, а не сам увольняемый), а условие + // «IsManager» на бэкенде вычисляется из поля Position — передаём его в + // fieldValues, иначе шаг с этим условием получит предупреждение «должность не + // передана» (см. TemplateRouteService.ResolveAsync в approval-route). + const loadMatrix = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId) + : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [ + { fieldName: 'Position', value: data.Position } + ]), + [context, approval, editId, data.Employee, data.Position, requestTypeId] + ); + const matrix = useMatrixEditor( + loadMatrix, + `${data.Employee}-${editId ?? 'new'}-${data.Position}`, + !readOnly, + !!editId || (!!data.Employee && !!requestTypeId), + (login) => approval.getJobTitle(login) + ); + + const isValid = !!(data.Employee && data.Position.trim() && data.TerminationDate); + const validate = (): string | undefined => { + if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата расторжения договора.'; + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; + + const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => + rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order, + // Транзитом из маршрута в строку списка: наследование бэкенд применил + // при resolve, форма только переносит значения (см. IMatrixRow.slaHours). + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason + })); + + const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } + const { id, code } = await saveClearanceRequest(requestId, rtId, data, toApprovers(matrix.rows), status); + setRequestId(id); + setRequestStatus(status); + if (code) { + setRequestCode(code); + } + if (onSaved) { + onSaved(); + } + // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). + if (status === REQUEST_STATUS.onApproval) { + await notifyActiveStep(context, approval, id, { + requestTypeId: rtId, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE + }); + } + }; + + // На отправку (в отличие от черновика) маршрут должен быть полностью + // укомплектован — иначе шаг без назначенного человека молча пропадёт при записи. + const validateSubmit = (): string | undefined => { + const formError = validate(); + if (formError) return formError; + if (matrix.error) { + return `Не удалось определить маршрут согласования: ${matrix.error}. Попробуйте ещё раз или обратитесь к администратору.`; + } + const unassigned = findUnassignedStep(matrix.rows); + if (unassigned) { + return `Шаг ${unassigned.order} маршрута согласования («${unassigned.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.`; + } + return undefined; + }; + + const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); + const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + + const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; + + return ( +
+ + + } + headerRight={ + requestId || requestStatus ? ( + <> + {requestStatus && } + {requestCode && } + + ) : undefined + } + > + {submit.savedKind && !submit.error && ( + + {submit.savedKind === 'draft' ? 'Черновик сохранён.' : 'Заявка отправлена на согласование.'} + + )} + {submit.error && {submit.error}} + + + + + + approval.searchUsers(q)} /> + + + +
+ ); +}; + +export default ClearanceForm; diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss b/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss new file mode 100644 index 0000000..78c0f57 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.module.scss @@ -0,0 +1,46 @@ +@import '../../../../styles/design-tokens'; + +.state { + padding: $sp-8; + color: $gray-500; + font-size: $fs-sm; + text-align: center; +} + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: $sp-4; +} + +// Полоса «вы можете заполнить свои отметки» для профильного согласующего +// (КДП / отдел подбора) — см. approverRights.ts. +.reviewBar { + display: flex; + align-items: center; + gap: $sp-3; + margin-bottom: $sp-4; + padding: $sp-3 $sp-4; + border: 1px solid $gold-300; + border-radius: $r-md; + background: $gold-50; +} + +.reviewHint { + flex: 1; + min-width: 0; + font-size: $fs-sm; + color: $navy-800; +} + +.savedNote { + font-size: $fs-sm; + color: $green-600; + font-weight: 600; +} + +.saveError { + font-size: $fs-sm; + color: $red-500; + font-weight: 600; +} diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx new file mode 100644 index 0000000..bb50f22 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceView.tsx @@ -0,0 +1,223 @@ +import * as React from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import styles from './ClearanceView.module.scss'; +import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintButton } from '../../shared/ui'; +import { + ApproverService, + DecisionPanel, + StatusBadge, + ApprovalMatrix, + useMatrixEditor, + notifyActiveStep, + useRequestTypeId, + APPROVAL_PHASE +} from '../../shared/approval'; +import { IFormData, EMPTY_DATA, SetField } from './model'; +import { loadClearanceRequest } from './load'; +import { + clearanceApprovalService, + updateApproverFields, + IApproverEditableFields, + DATA_LIST_TITLE, + APPROVAL_LIST_TITLE +} from './submit'; +import { canEditHrRecordsFields, canEditParkingField } from './approverRights'; +import { RequestSection } from './sections/RequestSection'; + +interface IClearanceViewProps { + context: WebPartContext; + itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; +} + +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; + +// Обходной лист в режиме согласующего. Форма read-only, КРОМЕ отметок, которые +// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения, +// отдел подбора — парковочная карта (см. approverRights.ts). +const ClearanceView: React.FC = ({ context, itemId, formKey }) => { + const [data, setData] = useState(EMPTY_DATA); + const [status, setStatus] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hrRecordsEditable, setHrRecordsEditable] = useState(false); + const [parkingEditable, setParkingEditable] = useState(false); + const [saveState, setSaveState] = useState('idle'); + + const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); + + const approval = useMemo(() => clearanceApprovalService(), []); + const { id: requestTypeId } = useRequestTypeId(context, formKey); + const approverService = useMemo(() => new ApproverService(approval, DATA_LIST_TITLE), [approval]); + + // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в + // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. + const [decisionSeq, setDecisionSeq] = useState(0); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => + approval.getJobTitle(l) + ); + + useEffect(() => { + let alive = true; + setLoading(true); + loadClearanceRequest(itemId) + .then((loaded) => { + if (alive) { + setData(loaded.data); + setStatus(loaded.status); + setCode(loaded.code); + setError(null); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError(`Не удалось загрузить заявку №${itemId}: ${(e as Error).message}`); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [itemId]); + + // Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя. + // isDelegator намеренно не даёт прав: делегировавший не решает за делегата + // (см. DecisionPanel), значит и отметки за него ставить не должен. + useEffect(() => { + let alive = true; + approverService + .getPendingRowForCurrentUser(itemId) + .then((pending) => { + if (!alive) return; + const active = !!pending && !pending.isDelegator && pending.phase !== APPROVAL_PHASE.execution; + setHrRecordsEditable(active && canEditHrRecordsFields(pending?.position)); + setParkingEditable(active && canEditParkingField(pending?.position)); + }) + .catch(() => { + if (alive) { + setHrRecordsEditable(false); + setParkingEditable(false); + } + }); + return () => { + alive = false; + }; + // decisionSeq: после решения/делегирования активный шаг меняется — права пересчитываем. + }, [itemId, approverService, decisionSeq]); + + // Пишем ТОЛЬКО те отметки, на которые у согласующего есть права: иначе он + // затёр бы чужие поля значениями, которые видел у себя на экране. + const saveApproverFields = useCallback((): Promise => { + const fields: Partial = {}; + if (hrRecordsEditable) { + fields.AccessCard = data.AccessCard; + fields.TrainingAmount = data.TrainingAmount; + } + if (parkingEditable) { + fields.ParkingCard = data.ParkingCard; + } + return updateApproverFields(itemId, fields); + }, [itemId, hrRecordsEditable, parkingEditable, data.AccessCard, data.TrainingAmount, data.ParkingCard]); + + const handleSaveFields = async (): Promise => { + setSaveState('saving'); + try { + await saveApproverFields(); + setSaveState('saved'); + } catch { + setSaveState('error'); + } + }; + + if (loading) { + return
Загрузка заявки…
; + } + if (error) { + return ( +
+ {error} +
+ ); + } + + const canEditAnything = hrRecordsEditable || parkingEditable; + const sectionProps = { data, set, context, hrRecordsEditable, parkingEditable }; + + const notify = (): void => { + // requestTypeId ещё не резолвился — решение уже применилось (строка списка + // обновлена), просто нечего уведомлять на следующем шаге; не проваливаем + // весь UI ради необязательного оповещения. + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE + }).catch(() => undefined); + }; + + return ( +
+
+ +
+ + {canEditAnything && ( +
+ + {hrRecordsEditable + ? 'Как согласующий вы можете отметить карту СКУД и указать сумму за обучение.' + : 'Как согласующий вы можете отметить парковочную карту.'} + + + {saveState === 'saved' && Сохранено} + {saveState === 'error' && Ошибка сохранения} +
+ )} + +
+ } + headerRight={status ? : undefined} + > + + + + + + approval.searchUsers(q)} /> +
+ + {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} +
+ approval.searchUsers(q)} + // Отметки сохраняются вместе с решением — иначе согласующий, забывший + // нажать «Сохранить», согласовал бы заявку со старыми значениями. + onBeforeDecide={canEditAnything ? saveApproverFields : undefined} + onDecided={(newStatus) => { + setStatus(newStatus); + setDecisionSeq((n) => n + 1); + notify(); + }} + onDelegated={() => { + setDecisionSeq((n) => n + 1); + notify(); + }} + /> +
+
+ ); +}; + +export default ClearanceView; diff --git a/src/webparts/requestPortal/forms/Clearance/README.md b/src/webparts/requestPortal/forms/Clearance/README.md new file mode 100644 index 0000000..56a1f96 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -0,0 +1,48 @@ +# Обходной лист (clearance-form) + +**Назначение:** оформление увольнения — обход подразделений (склад, СКУД, обучение) с отметками и согласованием. +**Категория:** Отдел кадров +**Списки SP:** «Обходной лист — данные» / «Обходной лист — согласование» +**RequestTypeId (внешний API маршрутов):** не хардкодится — резолвится в рантайме по +`pageUrl` («clearance-form») через `useRequestTypeId` (см. «Формы больше не хардкодят +RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирован в админке approval-route +(список данных «Обходной лист — данные», список SharePoint `/sites/requests/Lists/List10`). + +## Маршрут +- Фазы: только согласование. Фазы исполнения нет — после согласования заявка + просто получает статус «Согласовано» (как у «Заявки на отпуск»). +- **Матрица редактируема заявителем** при новой заявке / черновике / возврате: + `useMatrixEditor(editable = !readOnly)`. Заявитель (HR) может добавлять/удалять/ + переставлять согласующих поверх планируемого маршрута. +- Условие `IsManager` в маршруте (если настроено в админке) резолвится по полю + **Position ЭТОЙ ЗАЯВКИ** — «должность» увольняемого сотрудника, а не должность + того, кто заполняет форму. Поэтому `Position` передаётся в `fieldValues` при + планировании маршрута (`getPlannedMatrix(..., [{ fieldName: 'Position', ... }])`). + Если поле пустое, шаг с этим условием попадёт в маршрут с предупреждением + «должность не передана» (см. approval-route `TemplateRouteService.ResolveAsync`). + +## Особое поведение +- **Заявку подаёт не сам увольняемый, а HR/руководитель — за другого сотрудника.** + Поле «Сотрудник» — People-picker (колонка User `Employee`), содержит e-mail + **увольняемого**. Планируемый маршрут резолвится по этому e-mail + (`getPlannedMatrix(..., data.Employee, ...)`), а не по e-mail текущего + пользователя — как в «Заявке на отпуск». +- **Автоподстановка должности при выборе сотрудника** — только отправная точка: + подтягивается текущая должность из профиля (`SPS-JobTitle` через + `approval.getJobTitle`), но её можно поправить вручную (например, если + человек занимал руководящую позицию до перевода на другую). +- **Да/нет-поля** («Парковочная карта», «Карта СКУД») — `Toggle`, колонки + `ParkingCard`/`AccessCard` (Boolean). +- **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое» + число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное. +- **Дата расторжения договора** — обычный `input type=date`, без вычислений + (в отличие от `EndDate` в «Заявке на отпуск», которая считается). +- **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`. + +## Секции формы +RequestSection (сотрудник · должность · подразделение · дата расторжения · сумма обучения · парковочная карта · карта СКУД) + +## Точки входа +- rptype: `clearance-form` +- Режим согласующего (просмотр): есть (`ClearanceView`) +- «Создать на основе»: нет diff --git a/src/webparts/requestPortal/forms/Clearance/approverRights.ts b/src/webparts/requestPortal/forms/Clearance/approverRights.ts new file mode 100644 index 0000000..52bcc91 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/approverRights.ts @@ -0,0 +1,53 @@ +/** + * Кто из согласующих какие поля обходного листа может править на своём шаге. + * + * Инициатор (HR) отправляет заявку с пустыми отметками — фактические данные знают + * профильные подразделения, каждое своё: + * - отдел кадрового делопроизводства — карта СКУД и сумма за обучение; + * - отдел подбора и адаптации персонала — парковочная карта. + * + * Должность берётся из строки согласования (колонка Title, см. IApprovalRow.position), + * то есть та, что подставил маршрут при планировании. Сравнение — по ключевым словам, + * а не по точной строке: формулировки в справочнике различаются («Начальник отдела…», + * «Руководитель отдела…»), а тратить на это отдельную настройку в админке смысла нет. + * + * ВАЖНО про требование управленческого маркера (начальник/руководитель): без него + * права получили бы и рядовые сотрудники этих отделов («Специалист по направлению + * кадрового делопроизводства» в справочнике есть), оказавшиеся согласующими на любом + * другом шаге. Если понадобится отдать права и специалистам — убрать MANAGER_MARKERS + * из проверки. + */ + +const MANAGER_MARKERS = ['начальник', 'руководитель']; + +/** Отдел кадрового делопроизводства (КДП) — правит карту СКУД и сумму обучения. */ +const HR_RECORDS_MARKERS = ['кадров', 'делопроизводств']; + +/** Отдел подбора и адаптации персонала — правит парковочную карту. */ +const RECRUITING_MARKERS = ['подбор', 'адаптац']; + +function normalize(position: string | undefined): string { + return (position || '').toLowerCase(); +} + +function matchesAll(position: string, markers: string[]): boolean { + return markers.every((m) => position.indexOf(m) !== -1); +} + +function isManagerOf(position: string | undefined, departmentMarkers: string[]): boolean { + const normalized = normalize(position); + if (!normalized) return false; + return ( + MANAGER_MARKERS.some((m) => normalized.indexOf(m) !== -1) && matchesAll(normalized, departmentMarkers) + ); +} + +/** Карта СКУД и сумма обучения — начальник отдела кадрового делопроизводства. */ +export function canEditHrRecordsFields(position: string | undefined): boolean { + return isManagerOf(position, HR_RECORDS_MARKERS); +} + +/** Парковочная карта — начальник отдела подбора и адаптации персонала. */ +export function canEditParkingField(position: string | undefined): boolean { + return isManagerOf(position, RECRUITING_MARKERS); +} diff --git a/src/webparts/requestPortal/forms/Clearance/load.ts b/src/webparts/requestPortal/forms/Clearance/load.ts new file mode 100644 index 0000000..c7b8c14 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/load.ts @@ -0,0 +1,56 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { IFormData, EMPTY_DATA } from './model'; + +const LIST_TITLE = 'Обходной лист — данные'; + +export interface ILoadedRequest { + data: IFormData; + /** Ф.И.О. увольняемого сотрудника из поля Employee (для заголовка режима чтения). */ + employeeName: string; + status: string; + /** Уникальный код заявки; '' — если запись создана до появления поля. */ + code: string; +} + +/** Прочитать обходной лист по id: модель формы + статус (правка / просмотр). */ +export async function loadClearanceRequest(itemId: number): Promise { + const item = (await getSP() + .web.lists.getByTitle(LIST_TITLE) + .items.getById(itemId) + .select( + 'Id', + 'Position', + 'Department', + 'TerminationDate', + 'ParkingCard', + 'AccessCard', + 'TrainingAmount', + 'RequestStatus', + 'RequestUniqueCode', + 'Employee/Title', + 'Employee/EMail' + ) + .expand('Employee')()) as Record; + + const employee = item.Employee as { Title?: string; EMail?: string } | undefined; + const termination = (item.TerminationDate as string) || ''; + const amount = item.TrainingAmount; + + const data: IFormData = { + ...EMPTY_DATA, + Employee: employee?.EMail || '', + Position: (item.Position as string) || '', + Department: (item.Department as string) || '', + TerminationDate: termination.slice(0, 10), // ISO → YYYY-MM-DD для input type=date + ParkingCard: !!item.ParkingCard, + AccessCard: !!item.AccessCard, + TrainingAmount: amount === null || amount === undefined ? '' : String(amount) + }; + + return { + data, + employeeName: employee?.Title || '', + status: (item.RequestStatus as string) || '', + code: (item.RequestUniqueCode as string) || '' + }; +} diff --git a/src/webparts/requestPortal/forms/Clearance/model.ts b/src/webparts/requestPortal/forms/Clearance/model.ts new file mode 100644 index 0000000..68bbff0 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/model.ts @@ -0,0 +1,49 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; + +/** + * Данные формы «Обходной лист». Ключи по возможности = internalName колонок + * SP-списка «Обходной лист — данные». Employee хранит e-mail УВОЛЬНЯЕМОГО + * сотрудника (то, что отдаёт PeopleField) — заявку подаёт HR/руководитель, + * а не сам увольняемый, поэтому и маршрут согласования резолвится по email + * из этого поля, а не по email инициатора (как в «Заявке на отпуск»). + */ +export interface IFormData { + /** E-mail увольняемого сотрудника (People). Пусто = не выбран. */ + Employee: string; + Position: string; + Department: string; + /** ISO YYYY-MM-DD (input type=date). */ + TerminationDate: string; + ParkingCard: boolean; + AccessCard: boolean; + /** Сумма обучения — «сырые» цифры без пробелов (см. MoneyInput). Пусто = не указана. */ + TrainingAmount: string; +} + +/** Обновление одного поля формы с сохранением типа значения. */ +export type SetField = (key: K, value: IFormData[K]) => void; + +/** Пропсы секции формы: срез данных + колбэк обновления + контекст (people picker). */ +export interface ISectionProps { + data: IFormData; + set: SetField; + context: WebPartContext; + /** + * Выбор сотрудника подтягивает его должность — поэтому у поля «Сотрудник» + * отдельный обработчик. Если не передан (режим чтения) — правка через set. + */ + onEmployeeChange?: (login: string) => void; +} + +export const EMPTY_DATA: IFormData = { + Employee: '', + Position: '', + Department: '', + TerminationDate: '', + ParkingCard: false, + AccessCard: false, + TrainingAmount: '' +}; + +/** Плейсхолдер для поля выбора сотрудника. */ +export const EMPLOYEE_PLACEHOLDER = 'Начните вводить Ф.И.О. увольняемого сотрудника…'; diff --git a/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx new file mode 100644 index 0000000..28ae2d1 --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/sections/RequestSection.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { CollapseSection, FormField, TextInput, MoneyInput, Toggle, PeopleField } from '../../../shared/ui'; +import { ISectionProps, EMPLOYEE_PLACEHOLDER } from '../model'; + +// Должность/подразделение подтягиваются автоматически при выборе сотрудника +// (см. ClearanceForm.handleEmployeeChange — SharePoint User Profile) — +// свободный ввод здесь намеренно убран, чтобы не расходиться с профилем. +const NOOP = (): void => undefined; + +interface IRequestSectionProps extends ISectionProps { + /** Согласующий из КДП правит карту СКУД и сумму обучения даже в режиме просмотра. */ + hrRecordsEditable?: boolean; + /** Согласующий из отдела подбора правит парковочную карту. */ + parkingEditable?: boolean; +} + +/** Единственная секция формы «Обходной лист»: увольняемый, должность, подразделение, даты, карты, обучение. */ +export const RequestSection: React.FC = ({ + data, + set, + context, + onEmployeeChange, + hrRecordsEditable, + parkingEditable +}) => ( + + + set('Employee', v))} + placeholder={EMPLOYEE_PLACEHOLDER} + /> + + + + + + + + + set('TerminationDate', v)} + /> + + {/* Явный readOnly={false} перебивает read-only контекст просмотра (профильный + согласующий правит своё поле); undefined — обычное поведение из FormMode. */} + + set('TrainingAmount', v)} + readOnly={hrRecordsEditable ? false : undefined} + /> + + + set('ParkingCard', v)} + onLabel="Да" + readOnly={parkingEditable ? false : undefined} + /> + + + set('AccessCard', v)} + onLabel="Да" + readOnly={hrRecordsEditable ? false : undefined} + /> + + +); diff --git a/src/webparts/requestPortal/forms/Clearance/submit.ts b/src/webparts/requestPortal/forms/Clearance/submit.ts new file mode 100644 index 0000000..a8441ad --- /dev/null +++ b/src/webparts/requestPortal/forms/Clearance/submit.ts @@ -0,0 +1,112 @@ +import { getSP } from '../../shared/pnp/pnpjs'; +import { ApprovalListService, IApproverToWrite, writeRoute } from '../../shared/approval'; +import { buildRequestUniqueCode } from '../../shared/requestCode'; +import { IFormData } from './model'; + +export const DATA_LIST_TITLE = 'Обходной лист — данные'; +export const APPROVAL_LIST_TITLE = 'Обходной лист — согласование'; + +/** Экземпляр сервиса списка согласования этой формы. */ +export function clearanceApprovalService(): ApprovalListService { + return new ApprovalListService(APPROVAL_LIST_TITLE); +} + +/** Отметки, которые проставляет не инициатор, а профильный согласующий (см. approverRights.ts). */ +export interface IApproverEditableFields { + AccessCard: boolean; + TrainingAmount: string; + ParkingCard: boolean; +} + +/** + * Обновить только те отметки, что заполняет согласующий на своём шаге. + * Пишутся ровно переданные поля: у каждого отдела свой набор прав, и лишние + * ключи затёрли бы чужие отметки значениями из формы этого согласующего. + */ +export async function updateApproverFields( + id: number, + fields: Partial +): Promise { + const item: Record = {}; + if (fields.AccessCard !== undefined) item.AccessCard = fields.AccessCard; + if (fields.ParkingCard !== undefined) item.ParkingCard = fields.ParkingCard; + if (fields.TrainingAmount !== undefined) { + const amount = Number(fields.TrainingAmount); + item.TrainingAmount = + Number.isFinite(amount) && fields.TrainingAmount.trim() !== '' ? amount : null; + } + if (Object.keys(item).length === 0) return; + await getSP().web.lists.getByTitle(DATA_LIST_TITLE).items.getById(id).update(item); +} + +/** email / claims-логин → пользователь сайта (Id для Person-поля + Title для заголовка). */ +async function ensureEmployee(login: string): Promise<{ Id: number; Title: string }> { + const full = login.indexOf('|') >= 0 ? login : `i:0#.f|membership|${login}`; + return getSP().web.ensureUser(full); +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : `${n}`; +} + +/** Осмысленный заголовок элемента (системная колонка Title). */ +function buildTitle(employeeName: string): string { + const now = new Date(); + return `Обходной лист — ${employeeName} — ${pad2(now.getDate())}.${pad2(now.getMonth() + 1)}.${now.getFullYear()}`; +} + +/** Поля элемента списка данных из модели формы (+ Employee по Id). */ +function buildItem(data: IFormData, status: string, employeeId: number, employeeName: string): Record { + const amount = Number(data.TrainingAmount); + return { + Title: buildTitle(employeeName), + RequestStatus: status, + EmployeeId: employeeId, + Position: data.Position || null, + Department: data.Department || null, + TerminationDate: data.TerminationDate || null, + ParkingCard: data.ParkingCard, + AccessCard: data.AccessCard, + TrainingAmount: Number.isFinite(amount) && data.TrainingAmount.trim() !== '' ? amount : null + }; +} + +export interface ISaveResult { + id: number; + /** Задан только при создании новой заявки (существующий код не меняется). */ + code?: string; +} + +/** + * Сохранить обходной лист: содержимое + маршрут согласования. + * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. + * Фазы исполнения у обходного листа нет — writeRoute вызывается с пустым списком исполнителей. + * Возвращает Id заявки (+ код при создании). + */ +export async function saveClearanceRequest( + existingId: number | undefined, + requestTypeId: number, + data: IFormData, + approvers: IApproverToWrite[], + status: string +): Promise { + const list = getSP().web.lists.getByTitle(DATA_LIST_TITLE); + const employee = await ensureEmployee(data.Employee); + const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); + + let id: number; + let code: string | undefined; + if (existingId) { + await list.items.getById(existingId).update(item); + id = existingId; + } else { + const added = await list.items.add(item); + id = (added as { Id: number }).Id; + // Код известен только после создания элемента (нужен его Id). + code = buildRequestUniqueCode(requestTypeId, id, new Date()); + await list.items.getById(id).update({ RequestUniqueCode: code }); + } + + await writeRoute(clearanceApprovalService(), id, approvers, [], status); + return { id, code }; +} diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index b82f3aa..720d4fb 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -21,6 +21,7 @@ import { useMatrixEditor, getPlannedMatrix, notifyActiveStep, + useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, @@ -33,15 +34,10 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; -import { - saveHireRequest, - hireApprovalService, - HIRE_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { saveHireRequest, hireApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { loadHireRequest } from './load'; import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; @@ -94,21 +90,25 @@ const RecruitingHireForm: React.FC = ({ requestType, context, // редактирование, маршрут читается из уже записанных строк — планирование // затёрло бы правки, сделанные при первом сохранении. const approval = useMemo(() => hireApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; const loadMatrix = useCallback( () => editId ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), - [context, userEmail, approval, editId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval'), + [context, userEmail, approval, editId, requestTypeId] ); const matrix = useMatrixEditor( loadMatrix, `${userEmail}-${editId ?? 'new'}`, !readOnly, - true, + !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); @@ -119,20 +119,24 @@ const RecruitingHireForm: React.FC = ({ requestType, context, ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, HIRE_REQUEST_TYPE_ID, userEmail, approval, 'Execution'), - [context, userEmail, approval, editId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution'), + [context, userEmail, approval, editId, requestTypeId] ); const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}`, false, - true, + !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0; - const validate = (): string | undefined => - isValid ? undefined : 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.'; + const validate = (): string | undefined => { + if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.'; + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => // order обязателен: параллельные шаги делят один номер, по позиции в @@ -159,8 +163,16 @@ const RecruitingHireForm: React.FC = ({ requestType, context, }; const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } const { id, code } = await saveHireRequest( requestId, + rtId, data, toApprovers(matrix.rows), toApprovers(executors.rows), @@ -182,7 +194,7 @@ const RecruitingHireForm: React.FC = ({ requestType, context, // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { - requestTypeId: HIRE_REQUEST_TYPE_ID, + requestTypeId: rtId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE, }); diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx index 4bc3818..eabf02f 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireView.tsx @@ -10,6 +10,7 @@ import { ApprovalMatrix, useMatrixEditor, notifyActiveStep, + useRequestTypeId, APPROVAL_PHASE } from '../../shared/approval'; import { navigate } from '../../shared/routing'; @@ -17,7 +18,7 @@ import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attach import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadHireRequest } from './load'; import { confirmDiscardFormChanges } from '../../shared/unsavedChanges'; -import { hireApprovalService, HIRE_REQUEST_TYPE_ID, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; +import { hireApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { ApplicationSection } from './sections/ApplicationSection'; import { CandidateSection } from './sections/CandidateSection'; import { PpeSection } from './sections/PpeSection'; @@ -26,6 +27,8 @@ import { ConditionsSection } from './sections/ConditionsSection'; interface IRecruitingHireViewProps { context: WebPartContext; itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; } // Ключ формы «Организация рабочего места» в реестре (data/requests) — цель @@ -38,7 +41,7 @@ const NOOP_SET: SetField = () => undefined; // Заявка «на прием» только для просмотра (режим согласующего). // Переиспользует те же секции, что и форма подачи; редактирование выключено // через — поля сами становятся неизменяемыми. -const RecruitingHireView: React.FC = ({ context, itemId }) => { +const RecruitingHireView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -46,6 +49,8 @@ const RecruitingHireView: React.FC = ({ context, itemI const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { id: requestTypeId } = useRequestTypeId(context, formKey); + // Сервис решений согласующего: список согласования + список данных этой формы. const approverService = useMemo( () => new ApproverService(hireApprovalService(), DATA_LIST_TITLE), @@ -120,6 +125,15 @@ const RecruitingHireView: React.FC = ({ context, itemI const sectionProps = { data, set: NOOP_SET, context }; + const notify = (): void => { + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE, + }).catch(() => undefined); + }; + return (
@@ -187,21 +201,13 @@ const RecruitingHireView: React.FC = ({ context, itemI setStatus(newStatus); setDecisionSeq((n) => n + 1); // Очередь сдвинулась — уведомляем следующий активный шаг. - notifyActiveStep(context, approval, itemId, { - requestTypeId: HIRE_REQUEST_TYPE_ID, - dataListName: DATA_LIST_TITLE, - approvalListName: APPROVAL_LIST_TITLE, - }).catch(() => undefined); + notify(); }} onDelegated={() => { // Шаг переназначен: активной стала строка делегата — обновляем // матрицу и уведомляем нового активного участника. setDecisionSeq((n) => n + 1); - notifyActiveStep(context, approval, itemId, { - requestTypeId: HIRE_REQUEST_TYPE_ID, - dataListName: DATA_LIST_TITLE, - approvalListName: APPROVAL_LIST_TITLE, - }).catch(() => undefined); + notify(); }} />
diff --git a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts index 99cffe0..79fdebc 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingHire/submit.ts @@ -5,9 +5,6 @@ import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Прием на работу — данные'; export const APPROVAL_LIST_TITLE = 'Прием на работу — согласование'; -// Тип заявки «Заявка на прием» во внешнем approval-route API -// (GET /api/request-types → id 5). Захардкожен, как «отпуск = 1» в pto-tracker. -export const HIRE_REQUEST_TYPE_ID = 5; // Числовые поля: пустая строка → null, иначе Number(). const NUMBER_FIELDS: (keyof IFormData)[] = ['PpeHeight', 'PpeShoeSize', 'SalaryProbation', 'SalaryAfterProbation']; @@ -53,6 +50,7 @@ export interface ISaveResult { */ export async function saveHireRequest( existingId: number | undefined, + requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], @@ -70,7 +68,7 @@ export async function saveHireRequest( const added = await list.items.add(item); id = (added as { Id: number }).Id; // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(HIRE_REQUEST_TYPE_ID, id, new Date()); + code = buildRequestUniqueCode(requestTypeId, id, new Date()); await list.items.getById(id).update({ RequestUniqueCode: code }); } diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index 5470405..15f58e6 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -21,6 +21,7 @@ import { useMatrixEditor, getPlannedMatrix, notifyActiveStep, + useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, @@ -32,15 +33,10 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; -import { - saveSelectionRequest, - selectionApprovalService, - RECRUITING_SELECTION_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { saveSelectionRequest, selectionApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { loadSelectionRequest } from './load'; import { ApplicationSection } from './sections/ApplicationSection'; import { DepartmentSection } from './sections/DepartmentSection'; @@ -91,27 +87,33 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con // У заявки на редактировании маршрут читается из уже записанных строк, чтобы не // затереть правки инициатора при первом сохранении. const approval = useMemo(() => selectionApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; const loadMatrix = useCallback( () => editId ? approval.getMatrixForRequest(editId) - : getPlannedMatrix(context, RECRUITING_SELECTION_REQUEST_TYPE_ID, userEmail, approval, 'Approval'), - [context, userEmail, approval, editId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval'), + [context, userEmail, approval, editId, requestTypeId] ); const matrix = useMatrixEditor( loadMatrix, `${userEmail}-${editId ?? 'new'}`, !readOnly, - true, + !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); const isValid = !!(data.Responsible && data.DepartmentName.trim() && data.Position.trim()); - const validate = (): string | undefined => - isValid - ? undefined - : 'Заполните обязательные поля: ответственный за подачу, название отдела, должность.'; + const validate = (): string | undefined => { + if (!isValid) return 'Заполните обязательные поля: ответственный за подачу, название отдела, должность.'; + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ @@ -135,7 +137,14 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con }; const save = (status: string): (() => Promise) => async (): Promise => { - const { id, code } = await saveSelectionRequest(requestId, data, toApprovers(matrix.rows), status); + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } + const { id, code } = await saveSelectionRequest(requestId, rtId, data, toApprovers(matrix.rows), status); setRequestId(id); setRequestStatus(status); if (code) { @@ -152,7 +161,7 @@ const RecruitingSelectionForm: React.FC = ({ requestType, con // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { - requestTypeId: RECRUITING_SELECTION_REQUEST_TYPE_ID, + requestTypeId: rtId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE }); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx index f0357dc..81ca4e0 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionView.tsx @@ -9,17 +9,13 @@ import { StatusBadge, ApprovalMatrix, useMatrixEditor, - notifyActiveStep + notifyActiveStep, + useRequestTypeId } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadSelectionRequest } from './load'; -import { - selectionApprovalService, - RECRUITING_SELECTION_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { selectionApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { ApplicationSection } from './sections/ApplicationSection'; import { DepartmentSection } from './sections/DepartmentSection'; import { VacancySection } from './sections/VacancySection'; @@ -29,6 +25,8 @@ import { ConditionsSection } from './sections/ConditionsSection'; interface IRecruitingSelectionViewProps { context: WebPartContext; itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; } // В режиме чтения поля не редактируются, поэтому обновление — пустышка. @@ -36,7 +34,7 @@ const NOOP_SET: SetField = () => undefined; // Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же // секции, что и форма подачи; редактирование выключено через . -const RecruitingSelectionView: React.FC = ({ context, itemId }) => { +const RecruitingSelectionView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -45,6 +43,7 @@ const RecruitingSelectionView: React.FC = ({ cont const [error, setError] = useState(null); const approval = useMemo(() => selectionApprovalService(), []); + const { id: requestTypeId } = useRequestTypeId(context, formKey); const approverService = useMemo(() => new ApproverService(approval, DATA_LIST_TITLE), [approval]); // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в @@ -94,8 +93,9 @@ const RecruitingSelectionView: React.FC = ({ cont const sectionProps = { data, set: NOOP_SET, context }; const notify = (): void => { + if (!requestTypeId) return; notifyActiveStep(context, approval, itemId, { - requestTypeId: RECRUITING_SELECTION_REQUEST_TYPE_ID, + requestTypeId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE }).catch(() => undefined); diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts index 26f63c5..59dc753 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts +++ b/src/webparts/requestPortal/forms/RecruitingSelection/submit.ts @@ -5,8 +5,6 @@ import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Заявка на подбор — данные'; export const APPROVAL_LIST_TITLE = 'Заявка на подбор — согласование'; -// Тип заявки во внешнем approval-route API (GET /api/request-types → id 4). -export const RECRUITING_SELECTION_REQUEST_TYPE_ID = 4; // Поля-люди (один человек): логин → ensureUser → Id в колонку Id. const PERSON_FIELDS: (keyof IFormData)[] = ['Responsible', 'ProposedMentor', 'DirectManager']; @@ -75,6 +73,7 @@ export interface ISaveResult { */ export async function saveSelectionRequest( existingId: number | undefined, + requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], status: string @@ -93,7 +92,7 @@ export async function saveSelectionRequest( const added = await list.items.add(item); id = (added as { Id: number }).Id; // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(RECRUITING_SELECTION_REQUEST_TYPE_ID, id, new Date()); + code = buildRequestUniqueCode(requestTypeId, id, new Date()); await list.items.getById(id).update({ RequestUniqueCode: code }); } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index f3df323..2e369ac 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -21,6 +21,7 @@ import { useMatrixEditor, getPlannedMatrix, notifyActiveStep, + useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, @@ -32,13 +33,13 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService, - TICKET1C_REQUEST_TYPE_ID, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; @@ -132,7 +133,11 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan }, [editId]); const approval = useMemo(() => ticket1cApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; // Маршрут полностью и однозначно определяется видом работ — настроен в админке // маршрутов (условные шаги по WorkType, см. fieldValues), а не выбирается @@ -143,16 +148,16 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan () => editId ? approval.getMatrixForRequest(editId) - : getPlannedMatrix(context, TICKET1C_REQUEST_TYPE_ID, userEmail, approval, 'Approval', null, [ + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [ { fieldName: 'WorkType', value: data.WorkType } ]), - [context, userEmail, approval, editId, data.WorkType] + [context, userEmail, approval, editId, data.WorkType, requestTypeId] ); const matrix = useMatrixEditor( loadMatrix, `${userEmail}-${editId ?? 'new'}-${data.WorkType}`, false, - !!editId || !!data.WorkType, + !!editId || (!!data.WorkType && !!requestTypeId), (login) => approval.getJobTitle(login) ); @@ -163,10 +168,14 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan data.DB1C && data.Question.trim() ); - const validate = (): string | undefined => - isValid - ? undefined - : 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.'; + const validate = (): string | undefined => { + if (!isValid) { + return 'Заполните обязательные поля: наименование задачи, вид работ, организация, база 1С, вопрос.'; + } + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ @@ -190,6 +199,13 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan }; const save = (intendedStatus: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } // Пустой маршрут — согласование не требуется по правилам этого вида работ. const hasApprovers = matrix.rows.length > 0; const finalStatus = @@ -220,7 +236,7 @@ const Ticket1CForm: React.FC = ({ requestType, context, onCan if (finalStatus === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { - requestTypeId: TICKET1C_REQUEST_TYPE_ID, + requestTypeId: rtId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE }); diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index 8ea7448..015be31 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -22,6 +22,7 @@ import { ApprovalMatrix, useMatrixEditor, notifyActiveStep, + useRequestTypeId, isEditableStatus, REQUEST_STATUS } from '../../shared/approval'; @@ -32,7 +33,6 @@ import { ticket1cApprovalService, getPlannerTaskId, setPlannerTaskId, - TICKET1C_REQUEST_TYPE_ID, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; @@ -44,6 +44,8 @@ import { ContactSection } from './sections/ContactSection'; interface ITicket1CViewProps { context: WebPartContext; itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; } /** Текст карточки Planner из данных заявки; rejection — автор и причина отклонения, если есть. */ @@ -75,7 +77,7 @@ function buildPlannerNotes( } // Заявка в отдел 1С только для просмотра (режим согласующего). -const Ticket1CView: React.FC = ({ context, itemId }) => { +const Ticket1CView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -83,6 +85,7 @@ const Ticket1CView: React.FC = ({ context, itemId }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { id: requestTypeId } = useRequestTypeId(context, formKey); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const approverService = useMemo( @@ -172,13 +175,22 @@ const Ticket1CView: React.FC = ({ context, itemId }) => { const sectionProps = { data, set, context }; + const notify = (): void => { + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE + }).catch(() => undefined); + }; + return (
{isEditableStatus(status) && (
diff --git a/src/webparts/requestPortal/forms/Ticket1C/submit.ts b/src/webparts/requestPortal/forms/Ticket1C/submit.ts index 23e74e0..73a06ff 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/submit.ts +++ b/src/webparts/requestPortal/forms/Ticket1C/submit.ts @@ -5,14 +5,9 @@ import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Заявка в отдел 1С — данные'; export const APPROVAL_LIST_TITLE = 'Заявка в отдел 1С — согласование'; -// Тип заявки для кода заявки, резолва маршрута (resolveRoute) и уведомлений -// (notifyActiveStep) — подтверждён через GET /api/request-types: id 10, -// name "Заявка в отдел 1С" (не путать с «Маршрут #8» — это id самого маршрута -// в админке, отдельная сущность от id типа заявки). -export const TICKET1C_REQUEST_TYPE_ID = 10; // Префикс уникального кода заявки — буквенная метка «1С» вместо числового id -// типа (10). Только для отображения/идентификации; на резолв маршрута и -// уведомления (там нужен числовой TICKET1C_REQUEST_TYPE_ID) не влияет. +// типа заявки. Числовой RequestTypeId (для резолва маршрута/уведомлений) +// больше не хардкодится — см. useRequestTypeId в Form.tsx/View.tsx. export const TICKET1C_CODE_PREFIX = '1С'; /** Экземпляр сервиса списка согласования этой формы. */ diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 3672f90..c11a273 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -21,6 +21,7 @@ import { useMatrixEditor, getPlannedMatrix, notifyActiveStep, + useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, @@ -33,15 +34,10 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; -import { - saveTmcTransferRequest, - tmcTransferApprovalService, - TMC_TRANSFER_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { saveTmcTransferRequest, tmcTransferApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { loadTmcTransferRequest } from './load'; import { HeaderSection } from './sections/HeaderSection'; import { ItemsSection } from './sections/ItemsSection'; @@ -68,7 +64,10 @@ const TmcTransferForm: React.FC = ({ requestType, context, on const today = new Date().toISOString().slice(0, 10); setData((prev) => ({ ...prev, - RequesterEmail: user.email || '', + // email — через effectiveUserEmail (учитывает отладочную подмену личности); + // displayName подменить нечем, справочник ФИО тут не читается — при + // подмене имя останется настоящим, для отладки это неважно. + RequesterEmail: effectiveUserEmail(context), AuthorName: user.displayName || '', RequestDate: prev.RequestDate || today })); @@ -100,7 +99,11 @@ const TmcTransferForm: React.FC = ({ requestType, context, on }, [editId]); const approval = useMemo(() => tmcTransferApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; // У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на // редактирование — читается из уже записанных строк. // projectId = oid проекта-источника: по нему бэкенд подставляет согласующего @@ -110,18 +113,18 @@ const TmcTransferForm: React.FC = ({ requestType, context, on // Маршрут согласования зависит от проекта-источника («Откуда»): пока он не // выбран, resolve вернёт 404 — не грузим, показываем заглушку. У открытой на // редактирование заявки маршрут уже записан, источник не нужен. - const matrixReady = !!editId || !!projectId; + const matrixReady = !!editId || (!!projectId && !!requestTypeId); // Исполнитель определяется проектом-получателем («Куда»): показываем блок с // заглушкой, пока «Куда» не выбрано. Источник тоже нужен, иначе resolve 404. - const executorsReady = !!editId || (!!projectId && !!targetProjectId); + const executorsReady = !!editId || (!!projectId && !!targetProjectId && !!requestTypeId); const loadMatrix = useCallback( () => editId ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, TMC_TRANSFER_REQUEST_TYPE_ID, userEmail, approval, 'Approval', null, undefined, projectId), - [context, userEmail, approval, editId, projectId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId), + [context, userEmail, approval, editId, projectId, requestTypeId] ); const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) => approval.getJobTitle(login) @@ -134,8 +137,8 @@ const TmcTransferForm: React.FC = ({ requestType, context, on ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, TMC_TRANSFER_REQUEST_TYPE_ID, userEmail, approval, 'Execution', null, undefined, projectId), - [context, userEmail, approval, editId, projectId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, undefined, projectId), + [context, userEmail, approval, editId, projectId, requestTypeId] ); const executors = useMatrixEditor( loadExecutors, @@ -148,10 +151,14 @@ const TmcTransferForm: React.FC = ({ requestType, context, on const hasItems = data.Items.some((i) => i.name.trim().length > 0); const isValid = data.SourceProject.trim().length > 0 && data.TargetProject.trim().length > 0 && hasItems; - const validate = (): string | undefined => - isValid - ? undefined - : 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.'; + const validate = (): string | undefined => { + if (!isValid) { + return 'Заполните обязательные поля: проект-источник, проект-получатель и хотя бы одну позицию ТМЦ.'; + } + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ @@ -172,8 +179,16 @@ const TmcTransferForm: React.FC = ({ requestType, context, on }; const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } const { id, code } = await saveTmcTransferRequest( requestId, + rtId, data, toApprovers(matrix.rows), toApprovers(executors.rows), @@ -194,7 +209,7 @@ const TmcTransferForm: React.FC = ({ requestType, context, on } if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { - requestTypeId: TMC_TRANSFER_REQUEST_TYPE_ID, + requestTypeId: rtId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE }); diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx index 45d6cd0..3456e50 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferView.tsx @@ -22,28 +22,26 @@ import { ApprovalMatrix, useMatrixEditor, notifyActiveStep, + useRequestTypeId, isEditableStatus, APPROVAL_PHASE } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadTmcTransferRequest } from './load'; -import { - tmcTransferApprovalService, - TMC_TRANSFER_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { tmcTransferApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { HeaderSection } from './sections/HeaderSection'; import { ItemsSection } from './sections/ItemsSection'; interface ITmcTransferViewProps { context: WebPartContext; itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; } // Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего). -const TmcTransferView: React.FC = ({ context, itemId }) => { +const TmcTransferView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -51,6 +49,7 @@ const TmcTransferView: React.FC = ({ context, itemId }) = const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { id: requestTypeId } = useRequestTypeId(context, formKey); // В просмотре форма read-only, но секции ждут set — даём no-op. const set: SetField = () => undefined; @@ -120,13 +119,22 @@ const TmcTransferView: React.FC = ({ context, itemId }) = const sectionProps = { data, set, context }; + const notify = (): void => { + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE + }).catch(() => undefined); + }; + return (
{isEditableStatus(status) && (
diff --git a/src/webparts/requestPortal/forms/TmcTransfer/submit.ts b/src/webparts/requestPortal/forms/TmcTransfer/submit.ts index 17a5eb7..894c116 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/submit.ts +++ b/src/webparts/requestPortal/forms/TmcTransfer/submit.ts @@ -6,9 +6,6 @@ import { IFormData } from './model'; export const DATA_LIST_TITLE = 'СЗ на перемещение ТМЦ — данные'; export const APPROVAL_LIST_TITLE = 'СЗ на перемещение ТМЦ — согласование'; -// Тип заявки во внешнем approval-route API — подтверждён через GET /api/request-types: -// id 12, name «Заявка на перемещение», pageUrl «wh-tmc-transfer», section «Снабжение». -export const TMC_TRANSFER_REQUEST_TYPE_ID = 12; /** Экземпляр сервиса списка согласования этой формы. */ export function tmcTransferApprovalService(): ApprovalListService { @@ -52,6 +49,7 @@ export interface ISaveResult { */ export async function saveTmcTransferRequest( existingId: number | undefined, + requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], @@ -69,7 +67,7 @@ export async function saveTmcTransferRequest( const added = await list.items.add(item); id = (added as { Id: number }).Id; // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(TMC_TRANSFER_REQUEST_TYPE_ID, id, new Date()); + code = buildRequestUniqueCode(requestTypeId, id, new Date()); await list.items.getById(id).update({ RequestUniqueCode: code }); } diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index c67e31c..0c5dac7 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -17,6 +17,7 @@ import { useMatrixEditor, getPlannedMatrix, notifyActiveStep, + useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, @@ -24,13 +25,7 @@ import { } from '../../shared/approval'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; -import { - saveVacationRequest, - vacationApprovalService, - VACATION_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { saveVacationRequest, vacationApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { loadVacationRequest } from './load'; import { RequestSection } from './sections/RequestSection'; @@ -45,6 +40,10 @@ const VacationForm: React.FC = ({ requestType, context, onCan const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const approval = useMemo(() => vacationApprovalService(), []); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; // Открыта СВОЯ заявка на редактирование (черновик/возврат) — подгружаем содержимое и статус. useEffect(() => { @@ -112,14 +111,14 @@ const VacationForm: React.FC = ({ requestType, context, onCan () => editId ? approval.getMatrixForRequest(editId) - : getPlannedMatrix(context, VACATION_REQUEST_TYPE_ID, data.Employee, approval, 'Approval'), - [context, approval, editId, data.Employee] + : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), + [context, approval, editId, data.Employee, requestTypeId] ); const matrix = useMatrixEditor( loadMatrix, `${data.Employee}-${editId ?? 'new'}`, !readOnly, - !!editId || !!data.Employee, + !!editId || (!!data.Employee && !!requestTypeId), (login) => approval.getJobTitle(login) ); @@ -131,8 +130,12 @@ const VacationForm: React.FC = ({ requestType, context, onCan Number.isFinite(daysNum) && daysNum > 0 ); - const validate = (): string | undefined => - isValid ? undefined : 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).'; + const validate = (): string | undefined => { + if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).'; + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => rows.map((r) => ({ @@ -147,7 +150,14 @@ const VacationForm: React.FC = ({ requestType, context, onCan })); const save = (status: string): (() => Promise) => async (): Promise => { - const { id, code } = await saveVacationRequest(requestId, data, toApprovers(matrix.rows), status); + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } + const { id, code } = await saveVacationRequest(requestId, rtId, data, toApprovers(matrix.rows), status); setRequestId(id); setRequestStatus(status); if (code) { @@ -159,7 +169,7 @@ const VacationForm: React.FC = ({ requestType, context, onCan // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { - requestTypeId: VACATION_REQUEST_TYPE_ID, + requestTypeId: rtId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE }); diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 1ea6e7f..d498638 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -9,21 +9,19 @@ import { StatusBadge, ApprovalMatrix, useMatrixEditor, - notifyActiveStep + notifyActiveStep, + useRequestTypeId } from '../../shared/approval'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadVacationRequest } from './load'; -import { - vacationApprovalService, - VACATION_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { vacationApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { RequestSection } from './sections/RequestSection'; interface IVacationViewProps { context: WebPartContext; itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; } // В режиме чтения поля не редактируются, поэтому обновление — пустышка. @@ -32,7 +30,7 @@ const NOOP_SET: SetField = () => undefined; // Заявка на отпуск только для просмотра (режим согласующего). Переиспользует ту // же секцию, что и форма подачи; редактирование выключено через // — поля сами становятся неизменяемыми. -const VacationView: React.FC = ({ context, itemId }) => { +const VacationView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -40,6 +38,7 @@ const VacationView: React.FC = ({ context, itemId }) => { const [error, setError] = useState(null); const approval = useMemo(() => vacationApprovalService(), []); + const { id: requestTypeId } = useRequestTypeId(context, formKey); const approverService = useMemo(() => new ApproverService(approval, DATA_LIST_TITLE), [approval]); // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в @@ -88,8 +87,12 @@ const VacationView: React.FC = ({ context, itemId }) => { const sectionProps = { data, set: NOOP_SET, context }; const notify = (): void => { + // requestTypeId ещё не резолвился — решение уже применилось (строка списка + // обновлена), просто нечего уведомлять на следующем шаге; не проваливаем + // весь UI ради необязательного оповещения. + if (!requestTypeId) return; notifyActiveStep(context, approval, itemId, { - requestTypeId: VACATION_REQUEST_TYPE_ID, + requestTypeId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE }).catch(() => undefined); diff --git a/src/webparts/requestPortal/forms/Vacation/submit.ts b/src/webparts/requestPortal/forms/Vacation/submit.ts index 75e6237..a6a5fff 100644 --- a/src/webparts/requestPortal/forms/Vacation/submit.ts +++ b/src/webparts/requestPortal/forms/Vacation/submit.ts @@ -5,11 +5,6 @@ import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Заявка на отпуск — данные'; export const APPROVAL_LIST_TITLE = 'Заявка на отпуск — согласование'; -// Тип заявки «отпуск» во внешнем approval-route API (GET /api/request-types → id 11, -// name «Заявка на отпуск», pageUrl «hr-vacation»). Захардкожен, как -// HIRE_REQUEST_TYPE_ID=5 в RecruitingHire. NB: в старой web part pto-tracker было 1 — -// при заведении типа в админке портала он получил новый id 11, берём его. -export const VACATION_REQUEST_TYPE_ID = 11; /** Экземпляр сервиса списка согласования этой формы. */ export function vacationApprovalService(): ApprovalListService { @@ -67,6 +62,7 @@ export interface ISaveResult { */ export async function saveVacationRequest( existingId: number | undefined, + requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], status: string @@ -84,7 +80,7 @@ export async function saveVacationRequest( const added = await list.items.add(item); id = (added as { Id: number }).Id; // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(VACATION_REQUEST_TYPE_ID, id, new Date()); + code = buildRequestUniqueCode(requestTypeId, id, new Date()); await list.items.getById(id).update({ RequestUniqueCode: code }); } diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index a65016d..84cb067 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -21,6 +21,7 @@ import { useMatrixEditor, getPlannedMatrix, notifyActiveStep, + useRequestTypeId, REQUEST_STATUS, isEditableStatus, IApproverToWrite, @@ -33,15 +34,10 @@ import { deleteAttachment, IAttachmentInfo } from '../../shared/attachments/attachments.service'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { IFormData, EMPTY_DATA, SetField } from './model'; -import { - saveWorkplaceRequest, - workplaceApprovalService, - WORKPLACE_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { saveWorkplaceRequest, workplaceApprovalService, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { loadPrefillFromHire } from './prefill'; import { loadWorkplaceRequest } from './load'; import { EmployeeSection } from './sections/EmployeeSection'; @@ -113,7 +109,11 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, }, [sourceId]); const approval = useMemo(() => workplaceApprovalService(), []); - const userEmail = context.pageContext.user.email; + const userEmail = effectiveUserEmail(context); + // pageUrl формы (requestType.id совпадает с ним по конвенции портала, см. + // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. + const requestTypeIdState = useRequestTypeId(context, requestType.id); + const requestTypeId = requestTypeIdState.id; // У новой заявки маршрут ПЛАНИРУЕТСЯ (resolve по внешнему API), у открытой на // редактирование — читается из уже записанных строк: их могли поправить // вручную при первом сохранении, и планирование затёрло бы эти правки. @@ -123,15 +123,15 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Approval', data.DepartmentId), - [context, userEmail, approval, editId, data.DepartmentId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', data.DepartmentId), + [context, userEmail, approval, editId, data.DepartmentId, requestTypeId] ); const matrix = useMatrixEditor( loadMatrix, // Отдел влияет на резолв маршрута — смена отдела у НОВОЙ заявки должна перепланировать матрицу. `${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`, !readOnly, - true, + !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); @@ -142,20 +142,24 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, WORKPLACE_REQUEST_TYPE_ID, userEmail, approval, 'Execution', data.DepartmentId), - [context, userEmail, approval, editId, data.DepartmentId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', data.DepartmentId), + [context, userEmail, approval, editId, data.DepartmentId, requestTypeId] ); const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`, false, - true, + !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); const isValid = data.FullName.trim().length > 0 && data.Position.trim().length > 0; - const validate = (): string | undefined => - isValid ? undefined : 'Заполните обязательные поля: Ф.И.О., Должность.'; + const validate = (): string | undefined => { + if (!isValid) return 'Заполните обязательные поля: Ф.И.О., Должность.'; + if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; + if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; + return undefined; + }; const toApprovers = (rows: typeof matrix.rows): IApproverToWrite[] => // order обязателен: параллельные шаги делят один номер, по позиции в @@ -182,8 +186,16 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, }; const save = (status: string): (() => Promise) => async (): Promise => { + // validate()/validateSubmit() уже гарантировали requestTypeId к моменту вызова + // (см. useRequestSubmit.run — валидирует ДО save), но TS этого через границу + // замыкания не видит — локальный const и явная проверка снимают both проблемы. + const rtId = requestTypeId; + if (!rtId) { + throw new Error('Тип заявки не определён — обновите страницу и повторите.'); + } const { id, code } = await saveWorkplaceRequest( requestId, + rtId, data, toApprovers(matrix.rows), toApprovers(executors.rows), @@ -206,7 +218,7 @@ const WorkplaceSetupForm: React.FC = ({ requestType, context, // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). if (status === REQUEST_STATUS.onApproval) { await notifyActiveStep(context, approval, id, { - requestTypeId: WORKPLACE_REQUEST_TYPE_ID, + requestTypeId: rtId, dataListName: DATA_LIST_TITLE, approvalListName: APPROVAL_LIST_TITLE, }); diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx index 1f6e59a..3b5a9a3 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupView.tsx @@ -22,25 +22,22 @@ import { ApprovalMatrix, useMatrixEditor, notifyActiveStep, + useRequestTypeId, isEditableStatus, APPROVAL_PHASE } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { IFormData, EMPTY_DATA, SetField } from './model'; import { loadWorkplaceRequest } from './load'; -import { - workplaceApprovalService, - updateReviewFields, - WORKPLACE_REQUEST_TYPE_ID, - DATA_LIST_TITLE, - APPROVAL_LIST_TITLE -} from './submit'; +import { workplaceApprovalService, updateReviewFields, DATA_LIST_TITLE, APPROVAL_LIST_TITLE } from './submit'; import { EmployeeSection } from './sections/EmployeeSection'; import { EquipmentSection } from './sections/EquipmentSection'; interface IWorkplaceSetupViewProps { context: WebPartContext; itemId: number; + /** pageUrl формы — см. IApprovalViewProps в forms/viewRegistry.ts. */ + formKey: string; } type SaveState = 'idle' | 'saving' | 'saved' | 'error'; @@ -48,7 +45,7 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error'; // Заявка «орг. рабочего места» только для просмотра (режим согласующего). // Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель) // на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает). -const WorkplaceSetupView: React.FC = ({ context, itemId }) => { +const WorkplaceSetupView: React.FC = ({ context, itemId, formKey }) => { const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -58,6 +55,7 @@ const WorkplaceSetupView: React.FC = ({ context, itemI const [canEditFields, setCanEditFields] = useState(false); const [saveState, setSaveState] = useState('idle'); + const { id: requestTypeId } = useRequestTypeId(context, formKey); const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value })); const approverService = useMemo( @@ -172,15 +170,22 @@ const WorkplaceSetupView: React.FC = ({ context, itemI const sectionProps = { data, set, context }; + const notify = (): void => { + if (!requestTypeId) return; + notifyActiveStep(context, approval, itemId, { + requestTypeId, + dataListName: DATA_LIST_TITLE, + approvalListName: APPROVAL_LIST_TITLE, + }).catch(() => undefined); + }; + return (
{isEditableStatus(status) && (
diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts index 60e1f95..ec2f3f0 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/submit.ts @@ -5,8 +5,6 @@ import { IFormData } from './model'; export const DATA_LIST_TITLE = 'Организация рабочего места — данные'; export const APPROVAL_LIST_TITLE = 'Организация рабочего места — согласование'; -// Тип заявки во внешнем approval-route API (GET /api/request-types → id 7). -export const WORKPLACE_REQUEST_TYPE_ID = 7; /** Экземпляр сервиса списка согласования этой формы. */ export function workplaceApprovalService(): ApprovalListService { @@ -66,6 +64,7 @@ export interface ISaveResult { */ export async function saveWorkplaceRequest( existingId: number | undefined, + requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], executors: IApproverToWrite[], @@ -84,7 +83,7 @@ export async function saveWorkplaceRequest( const added = await list.items.add(item); id = (added as { Id: number }).Id; // Код известен только после создания элемента (нужен его Id). - code = buildRequestUniqueCode(WORKPLACE_REQUEST_TYPE_ID, id, new Date()); + code = buildRequestUniqueCode(requestTypeId, id, new Date()); await list.items.getById(id).update({ RequestUniqueCode: code }); } diff --git a/src/webparts/requestPortal/forms/registry.ts b/src/webparts/requestPortal/forms/registry.ts index 5f92615..0ac0bdb 100644 --- a/src/webparts/requestPortal/forms/registry.ts +++ b/src/webparts/requestPortal/forms/registry.ts @@ -30,6 +30,9 @@ const formRegistry: Record> = { ), 'wh-tmc-transfer': React.lazy( () => import(/* webpackChunkName: 'form-tmc-transfer' */ './TmcTransfer/TmcTransferForm') + ), + 'clearance-form': React.lazy( + () => import(/* webpackChunkName: 'form-clearance' */ './Clearance/ClearanceForm') ) }; diff --git a/src/webparts/requestPortal/forms/viewRegistry.ts b/src/webparts/requestPortal/forms/viewRegistry.ts index 5d43b19..d146894 100644 --- a/src/webparts/requestPortal/forms/viewRegistry.ts +++ b/src/webparts/requestPortal/forms/viewRegistry.ts @@ -4,6 +4,13 @@ import type { WebPartContext } from '@microsoft/sp-webpart-base'; export interface IApprovalViewProps { context: WebPartContext; itemId: number; + /** + * Ключ формы (= pageUrl типа заявки в approval-route API) — тот же, что и + * rptype в ссылке (см. ApproveFeature). Нужен режиму согласующего, чтобы + * самому резолвить числовой RequestTypeId через useRequestTypeId (форма + * подачи получает готовый requestType.id, а View — только itemId/context). + */ + formKey: string; } type ViewComponent = React.ComponentType; @@ -30,6 +37,9 @@ const viewRegistry: Record> = { ), 'wh-tmc-transfer': React.lazy( () => import(/* webpackChunkName: 'view-tmc-transfer' */ './TmcTransfer/TmcTransferView') + ), + 'clearance-form': React.lazy( + () => import(/* webpackChunkName: 'view-clearance' */ './Clearance/ClearanceView') ) }; diff --git a/src/webparts/requestPortal/loc/en-us.js b/src/webparts/requestPortal/loc/en-us.js index 3b25e74..6dbeb61 100644 --- a/src/webparts/requestPortal/loc/en-us.js +++ b/src/webparts/requestPortal/loc/en-us.js @@ -3,6 +3,8 @@ define([], function() { "PropertyPaneDescription": "Description", "BasicGroupName": "Group Name", "DescriptionFieldLabel": "Description Field", + "DebugUserEmailFieldLabel": "Отладка: работать от имени сотрудника", + "DebugUserEmailFieldDescription": "Только для отладки маршрутов. Портал будет считать текущим пользователем указанный e-mail: по нему планируется маршрут, отбираются «свои» заявки и очередь согласования. Прав это не даёт — запись в списки всё равно идёт под вашей учёткой. На боевой странице оставьте пустым.", "AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part", "AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app", "AppLocalEnvironmentOffice": "The app is running on your local environment in office.com", diff --git a/src/webparts/requestPortal/loc/mystrings.d.ts b/src/webparts/requestPortal/loc/mystrings.d.ts index 5cdfc88..8535e51 100644 --- a/src/webparts/requestPortal/loc/mystrings.d.ts +++ b/src/webparts/requestPortal/loc/mystrings.d.ts @@ -2,6 +2,8 @@ declare interface IRequestPortalWebPartStrings { PropertyPaneDescription: string; BasicGroupName: string; DescriptionFieldLabel: string; + DebugUserEmailFieldLabel: string; + DebugUserEmailFieldDescription: string; AppLocalEnvironmentSharePoint: string; AppLocalEnvironmentTeams: string; AppLocalEnvironmentOffice: string; diff --git a/src/webparts/requestPortal/shared/approval/approvalList.service.ts b/src/webparts/requestPortal/shared/approval/approvalList.service.ts index 214b91d..3482e26 100644 --- a/src/webparts/requestPortal/shared/approval/approvalList.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalList.service.ts @@ -5,6 +5,8 @@ import { IList } from '@pnp/sp/lists'; import '@pnp/sp/items'; import '@pnp/sp/site-users/web'; import '@pnp/sp/profiles'; +import '@pnp/sp/batching'; +import { getImpersonatedEmail } from '../impersonation'; import { IApprovalRow } from './models/IApprovalRow'; import { IMatrixRow } from './models/IMatrixRow'; import { ISiteUser } from './models/ISiteUser'; @@ -105,11 +107,31 @@ export class ApprovalListService { ); } - for (const { a, order } of withOrder.filter((x) => !!x.a.email)) { - const approverId = await this.resolveUserId(a.email); - await this.list().items.add({ + const toWrite = withOrder.filter((x) => !!x.a.email); + if (toWrite.length === 0) return; + + // Резолв Id согласующих — независимые запросы (ensureUser по email), + // ждать их по очереди незачем. На длинном маршруте (десяток+ шагов) это + // было основным источником задержки наравне с самой записью строк ниже. + const approverIds = await Promise.all(toWrite.map(({ a }) => this.resolveUserId(a.email))); + + // Сами строки — ОДНИМ HTTP-запросом через batch API SharePoint, а не по + // POST на строку: add() на batched-инстансе не уходит в сеть сразу, а + // копится и отправляется одним вызовом по execute(). + // + // ВАЖНО: batchedList (сам список) переиспользуем, а .items достаём ЗАНОВО + // на каждой итерации — PnPjs один и тот же Items-инстанс во второй add() + // внутри одного батча не даёт («This instance is already part of a batch», + // см. docs/concepts/batching#reuse). .getById(id) ниже в deleteForRequest + // этой проблемы не имеет — он и так создаёт новый инстанс на каждый вызов. + const [batchedSP, execute] = getSP().batched(); + const batchedList = batchedSP.web.lists.getByTitle(this.listTitle); + toWrite.forEach(({ a, order }, i) => { + // void: промис от add() на batched-инстансе нарочно не ждём поштучно — + // он разрешится вместе со всеми остальными после execute() ниже. + void batchedList.items.add({ Title: a.position || a.email, - [`${FIELDS.approver}Id`]: approverId, + [`${FIELDS.approver}Id`]: approverIds[i], [FIELDS.status]: status, [`${FIELDS.ticket}Id`]: requestId, [FIELDS.order]: order, @@ -119,7 +141,8 @@ export class ApprovalListService { [FIELDS.slaHours]: a.slaHours ?? null, [FIELDS.askOverdueReason]: a.askOverdueReason ?? null, }); - } + }); + await execute(); } // Продвигает очередь: среди ещё не активных строк со статусом «Ждёт очереди» @@ -205,6 +228,7 @@ export class ApprovalListService { .items.filter(`${FIELDS.ticket}Id eq ${requestId}`) .select( 'Id', + 'Title', FIELDS.status, FIELDS.order, FIELDS.allowReassign, @@ -224,6 +248,7 @@ export class ApprovalListService { approverId: approver?.Id ?? 0, order: (r[FIELDS.order] as number) ?? 0, allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false, + position: (r.Title as string) || '', // Пустая фаза (старые строки без колонки) трактуется как «Согласование». phase: (r[FIELDS.phase] as string) || APPROVAL_PHASE.approval, delegatedById: delegatedBy?.Id ?? 0, @@ -301,7 +326,15 @@ export class ApprovalListService { // сотрудника отдаёт именно e-mail (см. pickedLogin), а getPropertiesFor ждёт // account name — приводим e-mail к клеймовому виду так же, как ensure(). public async getJobTitle(loginName: string): Promise { - if (!loginName) return ''; + return (await this.getProfileFields(loginName)).jobTitle; + } + + // Должность + подразделение из профиля SharePoint (SPS-JobTitle/SPS-Department) + // ОДНИМ запросом — используется там, где нужны оба поля (напр. предзаполнение + // «Обходного листа»), чтобы не дёргать getPropertiesFor дважды подряд. + // '' по каждому полю, если недоступно/профиль не синхронизирован. + public async getProfileFields(loginName: string): Promise<{ jobTitle: string; department: string }> { + if (!loginName) return { jobTitle: '', department: '' }; const account = loginName.indexOf('|') >= 0 ? loginName : `i:0#.f|membership|${loginName}`; try { const props = (await getSP().profiles.getPropertiesFor(account)) as { @@ -313,9 +346,9 @@ export class ApprovalListService { : ((raw as { results?: Array<{ Key: string; Value: string }> })?.results ?? []); const map: Record = {}; for (const p of list) map[p.Key] = p.Value; - return map['SPS-JobTitle'] || map.Title || ''; + return { jobTitle: map['SPS-JobTitle'] || map.Title || '', department: map['SPS-Department'] || '' }; } catch { - return ''; + return { jobTitle: '', department: '' }; } } @@ -333,13 +366,28 @@ export class ApprovalListService { // Удаляет все строки согласования по заявке (при возврате/повторной отправке). public async deleteForRequest(requestId: number): Promise { const rows = await this.getForRequest(requestId); - for (const row of rows) { - await this.list().items.getById(row.id).delete(); - } + if (rows.length === 0) return; + + // writeRoute вызывает это перед перезаписью (пересохранение черновика, + // повторная отправка) — тот же per-row POST/DELETE в цикле, что и + // раньше в createSteps, и то же решение: один batch вместо N запросов. + const [batchedSP, execute] = getSP().batched(); + const batchedItems = batchedSP.web.lists.getByTitle(this.listTitle).items; + rows.forEach((row) => { + // void — по той же причине, что и в createSteps выше. + void batchedItems.getById(row.id).delete(); + }); + await execute(); } // Id текущего пользователя на сайте (для сравнения с полем «Согласующий»). + // При отладочной подмене личности (см. shared/impersonation.ts) отдаём Id + // подменённого сотрудника: иначе «моя очередь согласования» и делегирование + // считались бы по реальной учётке, и подмена не позволяла бы проверить шаг + // чужого согласующего — ради чего она и заводилась. public async getCurrentUserId(): Promise { + const impersonated = getImpersonatedEmail(); + if (impersonated) return this.resolveUserId(impersonated); const user = await getSP().web.currentUser.select('Id')(); return user.Id; } @@ -360,6 +408,24 @@ export class ApprovalListService { } } + // Id пользователя сайта → email. Обратное к resolveUserId: строки маршрута + // хранят согласующего числовым Id (Person-поле), а адрес нужен там, где + // решение принимается именно по почте — сейчас это заглушка рассылки + // (см. notify.service.ts). + // + // '' при любой неудаче (пользователя нет, поле пустое, запрос упал) — + // вызывающая сторона трактует это как «адрес неизвестен» и ведёт себя + // консервативно, а не считает, что проверка пройдена. + public async resolveUserEmail(userId: number): Promise { + if (!userId) return ''; + try { + const user = (await getSP().web.siteUsers.getById(userId).select('Email')()) as { Email?: string }; + return user.Email || ''; + } catch { + return ''; + } + } + private async ensure(email: string): Promise<{ Id: number; Title: string }> { const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`; return getSP().web.ensureUser(login); diff --git a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts index 78c6c60..975ff17 100644 --- a/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts +++ b/src/webparts/requestPortal/shared/approval/approvalRoute.service.ts @@ -24,6 +24,43 @@ export async function getRequestTypes(context: WebPartContext): Promise | undefined; + +async function getRequestTypesCached(context: WebPartContext): Promise { + if (!requestTypesPromise) { + requestTypesPromise = getRequestTypes(context).catch((e) => { + requestTypesPromise = undefined; + throw e; + }); + } + return requestTypesPromise; +} + +/** + * Числовой RequestTypeId по pageUrl (rptype) формы. Id типа заявки выдаётся + * сервером при регистрации типа в админке approval-route и НЕПРЕДСКАЗУЕМ + * заранее (см. README форм — «в старой pto-tracker было 1, при заведении типа + * в админке получил новый id 11»), а pageUrl задаём мы сами в data/requests.ts + * и он стабилен. Поэтому формы резолвят числовой id отсюда (через + * useRequestTypeId), а не хардкодят его. + */ +export async function getRequestTypeIdByPageUrl(context: WebPartContext, pageUrl: string): Promise { + const types = await getRequestTypesCached(context); + const found = types.filter((t) => t.pageUrl === pageUrl)[0]; + if (!found) { + throw new Error( + `Тип заявки с pageUrl «${pageUrl}» не найден в approval-route API — зарегистрируйте его в админке маршрутов (Типы заявок).` + ); + } + return found.id; +} + // POST /api/template-routes/resolve — планируемый маршрут для типа заявки и // пользователя. Бэкенд сам подставляет исполнителей шага (executorEmail/position) // по ролям исходя из userEmail. Параметр status выбирает ФАЗУ маршрута: diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 440b9db..08a35bc 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -19,7 +19,9 @@ export type { IResolveFieldValue, } from './models/IApprovalRouteApi'; -export { getRequestTypes, resolveRoute } from './approvalRoute.service'; +export { getRequestTypes, getRequestTypeIdByPageUrl, resolveRoute } from './approvalRoute.service'; +export { useRequestTypeId } from './useRequestTypeId'; +export type { IRequestTypeIdState } from './useRequestTypeId'; export { ApprovalListService } from './approvalList.service'; export type { IApproverToWrite } from './approvalList.service'; export { ApproverService } from './approverDecision.service'; diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts index 69d6d8a..6679460 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRow.ts @@ -5,6 +5,10 @@ export interface IApprovalRow { approverId: number; // Id пользователя сайта в поле «Согласующий» order: number; // колонка Order (очередь согласования) allowReassign: boolean; // AllowReassignToOtherApprovers + /** Должность/роль шага — колонка Title строки (см. createSteps: `a.position || a.email`). + * Нужна формам, где ПРАВА согласующего зависят от его должности (напр. «Обходной + * лист»: КДП правит СКУД и сумму обучения, подбор — парковочную карту). */ + position: string; phase: string; // Phase: «Согласование» | «Исполнение» (см. APPROVAL_PHASE) /** Id пользователя сайта, который делегировал ЭТУ строку (DelegatedBy). 0 — строка не делегирована * (пришла из маршрута напрямую). Позволяет делегировавшему переназначить шаг ещё раз, diff --git a/src/webparts/requestPortal/shared/approval/notify.service.ts b/src/webparts/requestPortal/shared/approval/notify.service.ts index d97740e..ad632eb 100644 --- a/src/webparts/requestPortal/shared/approval/notify.service.ts +++ b/src/webparts/requestPortal/shared/approval/notify.service.ts @@ -4,6 +4,21 @@ import { FLOW_RESOURCE, FLOW_NOTIFY_URL } 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 маршрутов) @@ -27,6 +42,24 @@ async function postNotify(context: WebPartContext, body: Record } } +// Разрешена ли отправка на согласующего активной строки (см. NOTIFY_ONLY_TO). +// +// Неизвестный адрес (пользователь не найден, поле пустое, запрос упал) — +// НЕ отправляем: пока заглушка включена, сомнение трактуется в пользу +// «письмо живому человеку лучше не слать», а не наоборот. +async function isNotifyAllowed(approval: ApprovalListService, approverId: number): Promise { + if (!NOTIFY_ONLY_TO) return true; + + const email = await approval.resolveUserEmail(approverId); + if (email.toLowerCase() === NOTIFY_ONLY_TO.toLowerCase()) return true; + + console.warn( + `[Оповещение] Включена заглушка рассылки: письмо для «${email || 'адрес неизвестен'}» не отправлено ` + + `(разрешён только ${NOTIFY_ONLY_TO})` + ); + return false; +} + // Находит АКТИВНЫЙ шаг маршрута (На согласовании / На исполнении) и уведомляет его. // Вызывать после отправки заявки и после каждого положительного решения. Поток по // переданным полям сам находит согласующего/исполнителя и шлёт уведомление. @@ -45,6 +78,9 @@ export async function notifyActiveStep( if (!active) { return; } + if (!(await isNotifyAllowed(approval, active.approverId))) { + return; + } await postNotify(context, { RequestTypeId: target.requestTypeId, RowID: active.id, diff --git a/src/webparts/requestPortal/shared/approval/useRequestTypeId.ts b/src/webparts/requestPortal/shared/approval/useRequestTypeId.ts new file mode 100644 index 0000000..6880679 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/useRequestTypeId.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import type { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getRequestTypeIdByPageUrl } from './approvalRoute.service'; + +export interface IRequestTypeIdState { + /** undefined, пока не резолвился (или если резолв упал — см. error). */ + id: number | undefined; + loading: boolean; + error: string | undefined; +} + +/** + * Резолвит числовой RequestTypeId по pageUrl формы (см. getRequestTypeIdByPageUrl). + * Формы больше не хардкодят число — используют этот хук с уже известным pageUrl: + * в форме подачи это requestType.id (проп IRequestFormProps), в режиме + * согласующего — formKey (проп IApprovalViewProps), см. ApproveFeature. + */ +export function useRequestTypeId(context: WebPartContext, pageUrl: string): IRequestTypeIdState { + const [id, setId] = useState(undefined); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(undefined); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(undefined); + getRequestTypeIdByPageUrl(context, pageUrl) + .then((v) => { + if (alive) { + setId(v); + setLoading(false); + } + }) + .catch((e: Error) => { + if (alive) { + setError(e.message); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [context, pageUrl]); + + return { id, loading, error }; +} diff --git a/src/webparts/requestPortal/shared/impersonation.ts b/src/webparts/requestPortal/shared/impersonation.ts new file mode 100644 index 0000000..ddc95fd --- /dev/null +++ b/src/webparts/requestPortal/shared/impersonation.ts @@ -0,0 +1,39 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; + +/** + * ОТЛАДОЧНАЯ подмена «текущего пользователя»: портал ведёт себя так, будто им + * пользуется указанный сотрудник. Нужна, чтобы проверять маршруты и права + * согласующего (например, «карту СКУД правит начальник КДП» в обходном листе), + * не выпрашивая учётку у каждого участника маршрута. + * + * Значение задаётся в свойствах веб-части (property pane, см. + * RequestPortalWebPart.getPropertyPaneConfiguration) и живёт на уровне модуля — + * иначе его пришлось бы протаскивать пропсами через всё дерево до каждой формы. + * + * ЧТО ЭТО МЕНЯЕТ: только то, ЧЕЙ email портал отправляет как инициатора при + * планировании маршрута и по какому пользователю фильтрует «свои» заявки и + * очередь согласования. + * + * ЧЕГО ЭТО НЕ МЕНЯЕТ: прав доступа. Запись в SharePoint по-прежнему идёт под + * реальной учёткой (Author/Editor в списках — настоящий пользователь), токен к + * approval-route API тоже настоящий. То есть подмена не даёт увидеть то, к чему + * нет доступа, но ПОЗВОЛЯЕТ принять решение за чужой шаг согласования — поэтому + * на бою поле должно быть пустым (см. предупреждающую полосу в RequestPortal). + */ + +let impersonatedEmail = ''; + +/** Вызывается веб-частью на каждый render — свойство могли поменять в панели. */ +export function setImpersonatedEmail(email: string | undefined): void { + impersonatedEmail = (email || '').trim(); +} + +/** Заданная подмена ('' — подмены нет). Для предупреждающей полосы в интерфейсе. */ +export function getImpersonatedEmail(): string { + return impersonatedEmail; +} + +/** Email, под которым портал работает: подменённый, если задан, иначе — из токена. */ +export function effectiveUserEmail(context: WebPartContext): string { + return impersonatedEmail || context.pageContext.user.email || ''; +}