diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 113265f..e09489c 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -126,20 +126,42 @@ const RecruitingHireForm: React.FC = ({ // data/requests.ts) → числовой RequestTypeId, нужный approval-route API. const requestTypeIdState = useRequestTypeId(context, requestType.id); const requestTypeId = requestTypeIdState.id; + // Маршрут для этого типа заявки зависит от проекта (ByProject) — без projectId + // бэкенд отвечает resolve: 404 «…и признака «по проекту» не найден» (поле + // Project изначально задумывалось справочным, но выяснилось, что маршрут + // всё-таки требует projectId, как в RecruitingSelection/TmcTransfer). + const projectId = data.ProjectId || null; + const matrixReady = !!editId || (!!projectId && !!requestTypeId); + // Несколько шагов маршрута условны по компании (см. панель «Маршруты»: + // «CompaniesJson = TOO QAZGEOMAX», «CompaniesJson = TOO CT Global», шаг с + // сразу ДВУМЯ условиями одновременно). Companies — мультивыбор → ОДНА запись + // fieldValues, value — имена выбранных компаний ЧЕРЕЗ ПРОБЕЛ (не JSON, не + // массив — ResolveFieldValueDto на бэкенде принимает только строку, массив + // даёт 400; сериализованный JSON не совпадает с условием при сравнении). + // Плюс шаг «Роль: Планировщик» условен по названию проекта («Project не + // содержит офис») — сюда идёт ТЕКСТ (data.Project), а не projectId (GUID), + // который уже передаётся отдельным параметром resolve. + const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' '); + const fieldValues = useMemo(() => { + const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : []; + return data.Project ? [...companies, { fieldName: 'Project', value: data.Project }] : companies; + // eslint-disable-next-line react-hooks/exhaustive-deps -- companiesFieldValue — стабильный слепок data.Companies + }, [companiesFieldValue, data.Project]); const loadMatrix = useCallback( () => editId ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase !== APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval'), - [context, userEmail, approval, editId, requestTypeId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues, projectId), + [context, userEmail, approval, editId, requestTypeId, fieldValues, projectId] ); const matrix = useMatrixEditor( loadMatrix, - `${userEmail}-${editId ?? 'new'}`, + // projectId/companies в ключе → при их смене маршрут перезапрашивается. + `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, !readOnly, - !!editId || !!requestTypeId, + matrixReady, (login) => approval.getJobTitle(login) ); @@ -150,14 +172,14 @@ const RecruitingHireForm: React.FC = ({ ? approval .getMatrixForRequest(editId) .then((rows) => rows.filter((r) => r.phase === APPROVAL_PHASE.execution)) - : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution'), - [context, userEmail, approval, editId, requestTypeId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', null, fieldValues, projectId), + [context, userEmail, approval, editId, requestTypeId, fieldValues, projectId] ); const executors = useMatrixEditor( loadExecutors, - `${userEmail}-${editId ?? 'new'}`, + `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, false, - !!editId || !!requestTypeId, + matrixReady, (login) => approval.getJobTitle(login) ); @@ -353,7 +375,12 @@ const RecruitingHireForm: React.FC = ({ )} - approval.searchUsers(q)} /> + approval.searchUsers(q)} + waiting={!matrixReady} + waitingText="Выберите «Проект», чтобы построить маршрут согласования." + /> {!executors.loading && executors.rows.length > 0 && ( = ({ // Новая заявка: матрицу резолвим только когда есть и тип, и проект — иначе // 404 «маршрут не найден». При редактировании читаем уже записанные строки. const matrixReady = !!editId || (!!projectId && !!requestTypeId); + // Условные шаги маршрута (см. [[request-portal-fieldvalues-routing]] — тот же + // разбор на RecruitingHire): fieldName = internalName колонки. Companies — + // мультивыбор → ОДНА запись fieldValues, value — имена выбранных компаний + // ЧЕРЕЗ ПРОБЕЛ (не JSON, не массив — ResolveFieldValueDto на бэкенде + // принимает только строку, массив даёт 400). ObjectProject — текст названия + // проекта (не ObjectProjectId/GUID, тот уже уходит отдельным параметром projectId). + const companiesFieldValue = data.Companies.map((c) => c.Name).sort().join(' '); + const fieldValues = useMemo(() => { + const companies = companiesFieldValue ? [{ fieldName: 'CompaniesJson', value: companiesFieldValue }] : []; + return data.ObjectProject ? [...companies, { fieldName: 'ObjectProject', value: data.ObjectProject }] : companies; + // eslint-disable-next-line react-hooks/exhaustive-deps -- companiesFieldValue — стабильный слепок data.Companies + }, [companiesFieldValue, data.ObjectProject]); const loadMatrix = useCallback( () => editId ? approval.getMatrixForRequest(editId) - : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId), - [context, userEmail, approval, editId, requestTypeId, projectId] + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, fieldValues, projectId), + [context, userEmail, approval, editId, requestTypeId, fieldValues, projectId] ); const matrix = useMatrixEditor( loadMatrix, - // projectId в ключе → при смене проекта маршрут перезапрашивается. - `${userEmail}-${editId ?? 'new'}-${projectId}`, + // projectId/companies в ключе → при их смене маршрут перезапрашивается. + `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, !readOnly, matrixReady, (login) => approval.getJobTitle(login) diff --git a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts index 1085577..ce7eb33 100644 --- a/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts +++ b/src/webparts/requestPortal/shared/approval/models/IApprovalRouteApi.ts @@ -71,7 +71,10 @@ export interface ICurrentListPartition { // «Показывать шаг только при выполнении условия», сравнение «Поле списка»/ // «Оператор»/«Значение»). fieldName — внутреннее имя колонки списка (напр. // "WorkType"), value — фактическое значение из формы (напр. "Создание -// пользователя в 1С"), с которым бэкенд сравнивает эту колонку. +// пользователя в 1С"), с которым бэкенд сравнивает эту колонку. ТОЛЬКО строка — +// бэкенд (ResolveFieldValueDto) не принимает массив в value (400 при попытке, +// см. [[request-portal-fieldvalues-routing]]). Для мультизначных полей (напр. +// CompaniesJson) — ОТДЕЛЬНАЯ запись с этим fieldName на КАЖДОЕ значение. export interface IResolveFieldValue { fieldName: string; value: string; diff --git a/src/webparts/requestPortal/shared/dictionary/models/IProject.ts b/src/webparts/requestPortal/shared/dictionary/models/IProject.ts index 99ed792..d3c1e60 100644 --- a/src/webparts/requestPortal/shared/dictionary/models/IProject.ts +++ b/src/webparts/requestPortal/shared/dictionary/models/IProject.ts @@ -25,7 +25,16 @@ export interface IProject { deleted: boolean; } -/** Обёртка ответа /Project. */ +/** + * Обёртка ответа /Project. Бэкенд игнорирует запрошенный pageSize и всегда + * отдаёт свой собственный размер страницы (напр. 100) — поэтому для остановки + * пагинации нужно сравнивать `page` с `totalPages` ИЗ ОТВЕТА, а не с тем, что + * запросили сами (см. projects.service.ts). + */ export interface IProjectListResponse { items: IProject[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; } diff --git a/src/webparts/requestPortal/shared/dictionary/projects.service.ts b/src/webparts/requestPortal/shared/dictionary/projects.service.ts index e2f7f3f..7ead6cd 100644 --- a/src/webparts/requestPortal/shared/dictionary/projects.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/projects.service.ts @@ -7,8 +7,8 @@ import { IProject, IProjectListResponse } from './models/IProject'; // ПАГИНИРОВАН, поэтому тянем ВСЕ страницы (иначе проекты за пределами первой // страницы не находятся в поиске пикера). Авторизация та же (AadHttpClient + // APPROVAL_API_CLIENT_ID, SSO текущего пользователя). -const PAGE_SIZE = 500; -const MAX_PAGES = 50; // страховка от бесконечного цикла (до 25 000 проектов) +const PAGE_SIZE = 500; // бэкенд может отдать меньше (см. IProjectListResponse) — не влияет на остановку цикла +const MAX_PAGES = 50; // страховка от бесконечного цикла, не привязана к PAGE_SIZE async function fetchProjects(context: WebPartContext): Promise { const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); @@ -24,8 +24,12 @@ async function fetchProjects(context: WebPartContext): Promise { const body: IProjectListResponse = await response.json(); const items = body.items ?? []; all.push(...items); - // Последняя страница — вернулось меньше запрошенного размера. - if (items.length < PAGE_SIZE) break; + // Бэкенд игнорирует наш pageSize и отдаёт свой (напр. 100) — сравнивать + // items.length с ЗАПРОШЕННЫМ PAGE_SIZE нельзя (он почти всегда больше + // реального размера страницы, цикл остановился бы после первой же + // страницы). Останавливаемся по totalPages ИЗ ОТВЕТА, а items.length===0 — + // страховка на случай, если бэкенд когда-нибудь перестанет отдавать totalPages. + if (items.length === 0 || (body.totalPages && page >= body.totalPages)) break; } return all; }