From 98226e4753529182eb693bdc6b4f84f16cfa25a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Wed, 23 Sep 2026 11:07:54 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=B5=D0=B4=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=C2=AB=D0=9A?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=C2=BB=20=D0=B2=20=D0=A2=D0=9C=D0=A6,=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BC=D0=BE=D1=80=D0=BE=D0=B7=D0=BA=D0=B0=20?= =?UTF-8?q?1=D0=A1,=20readOnly=20=C2=AB=D0=9E=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - СЗ на перемещение ТМЦ: поле «Куда» предзаполняется проектом-получателем по строке-источнику Dataverse (cree9_demandline -> lzk position -> LZK -> project id), см. shared/dataverse/lzkPosition.service.ts - Заявка 1С досрочно разморожена для всех пользователей (UNFROZEN_FORMS в SubmitActions), остальные формы остаются замороженными - Поле «Отдел» в заявке 1С сделано read-only Co-Authored-By: Claude Sonnet 5 --- .../forms/Ticket1C/Ticket1CForm.tsx | 1 + .../Ticket1C/sections/ContactSection.tsx | 2 +- .../forms/TmcTransfer/TmcTransferForm.tsx | 42 +++++++++ src/webparts/requestPortal/shared/config.ts | 21 +++++ .../shared/dataverse/lzkPosition.service.ts | 93 +++++++++++++++++++ .../shared/ui/SubmitActions/SubmitActions.tsx | 18 +++- 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dataverse/lzkPosition.service.ts diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index a58b61d..2775dc6 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -406,6 +406,7 @@ const Ticket1CForm: React.FC = ({ = ({ data, set, context }) set('Phone', v)} /> - set('Department', v)} /> + set('Department', v)} readOnly /> set('Position', v)} /> diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 9333134..456c1e9 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -40,11 +40,13 @@ import { getEmployeeOrganizations, formatEmployeeOrganizations, getNomenclature, + getProjects, INomenclatureRow } from '../../shared/dictionary'; import { ILineItem } from '../../shared/ui'; import { requestLinkUrl } from '../../shared/routing'; import { writeDemandLinePortalLink } from '../../shared/dataverse/demandLine.service'; +import { resolveLzkProjectId } from '../../shared/dataverse/lzkPosition.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { IRequestFormProps } from '../../models/types'; import { @@ -172,6 +174,46 @@ const TmcTransferForm: React.FC = ({ }; }, [editId, nomenclatureId, positionId, storeName, context, setSubmitError]); + // Проект-получатель («Куда») по той же внешней ссылке: строка-источник в + // Dataverse хранит ID позиции ЛЗК, у которой есть родительская запись LZK + // с проектом (см. shared/dataverse/lzkPosition.service). Необязательное + // удобство — на ошибке просто не предзаполняем поле, пользователь выберет + // проект вручную. + useEffect(() => { + if (editId || !positionId) { + return; + } + let alive = true; + resolveLzkProjectId(context, positionId) + .then((projectOid) => { + if (!alive || !projectOid) { + return undefined; + } + return getProjects(context).then((projects) => { + // GUID в Dataverse и в /Project могут отличаться регистром — сравниваем без него. + const project = projects.find((p) => p.oid.toLowerCase() === projectOid.toLowerCase()); + if (!project) { + // eslint-disable-next-line no-console + console.warn(`Проект ${projectOid} из LZK не найден в справочнике /Project — «Куда» не предзаполнено.`); + return; + } + if (alive) { + // prev.TargetProjectId уже могло быть выбрано пользователем вручную, + // пока запрос летал — не затираем его. + setData((prev) => (prev.TargetProjectId ? prev : { ...prev, TargetProject: project.name, TargetProjectId: project.oid })); + } + }); + }) + .catch((e) => { + // Необязательное удобство — форму не роняем, но причину оставляем в консоли. + // eslint-disable-next-line no-console + console.warn('Не удалось определить проект «Куда» по строке-источнику (Dataverse):', e); + }); + return () => { + alive = false; + }; + }, [editId, positionId, context]); + // Выбор другого заявителя подтягивает его ФИО (для печати) и организации (как // в Vacation.handleEmployeeChange, но источник тот же Employee/ByEmail — здесь // дополнительно берём ФИО, т.к. TMC хранит его отдельным полем AuthorName). diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 55c093f..a506d85 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -86,3 +86,24 @@ export const DATAVERSE_DEMANDLINE_ENTITY_SET = 'cree9_demandlines'; * как у остальных колонок таблицы — так сгенерировалось при создании колонки. */ export const DATAVERSE_DEMANDLINE_LINK_FIELD = 'cr498_portalrequesturl'; +/** + * Колонка «ID позиции ЛЗК» на «Строке заявки» — GUID строки cree9_lzkposition + * ХРАНИТСЯ ТЕКСТОМ (не lookup-связью), поэтому её нельзя $expand'нуть — только + * читать значение и адресовать таблицу lzk position отдельным запросом. + * Используется для определения проекта-получателя («Куда») в форме «СЗ на + * перемещение ТМЦ» — см. shared/dataverse/lzkPosition.service.ts. + */ +export const DATAVERSE_DEMANDLINE_LZKPOSITION_FIELD = 'cree9_lzkpositionid'; + +/** + * EntitySetName таблицы «lzk position» (LogicalName cree9_lzkposition) — выведено + * по той же конвенции (logical + 's'), что и DATAVERSE_DEMANDLINE_ENTITY_SET. + */ +export const DATAVERSE_LZKPOSITION_ENTITY_SET = 'cree9_lzkpositions'; +/** Lookup-поле на lzk position, ссылающееся на родительскую запись LZK. */ +export const DATAVERSE_LZKPOSITION_LZK_LOOKUP_FIELD = 'cree9_lzkid'; + +/** EntitySetName таблицы «LZK» (LogicalName cree9_lzk). */ +export const DATAVERSE_LZK_ENTITY_SET = 'cree9_lzks'; +/** Колонка «project id» на LZK — GUID проекта текстом, совпадает по формату с oid из /Project. */ +export const DATAVERSE_LZK_PROJECT_FIELD = 'cree9_projectid'; diff --git a/src/webparts/requestPortal/shared/dataverse/lzkPosition.service.ts b/src/webparts/requestPortal/shared/dataverse/lzkPosition.service.ts new file mode 100644 index 0000000..743037e --- /dev/null +++ b/src/webparts/requestPortal/shared/dataverse/lzkPosition.service.ts @@ -0,0 +1,93 @@ +import { AadHttpClient } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { + DATAVERSE_API_VERSION, + DATAVERSE_DEMANDLINE_ENTITY_SET, + DATAVERSE_DEMANDLINE_LZKPOSITION_FIELD, + DATAVERSE_LZK_ENTITY_SET, + DATAVERSE_LZK_PROJECT_FIELD, + DATAVERSE_LZKPOSITION_ENTITY_SET, + DATAVERSE_LZKPOSITION_LZK_LOOKUP_FIELD, + DATAVERSE_RESOURCE_URL +} from '../config'; + +/** + * Читает одну колонку записи Dataverse по её GUID. Пустое значение — undefined; + * ошибка ответа (нет записи, нет прав) — исключение с текстом от Dataverse, + * чтобы в консоли было видно, на каком звене цепочки обрыв. + */ +async function getFieldValue( + client: AadHttpClient, + entitySet: string, + id: string, + select: string +): Promise { + const url = `${DATAVERSE_RESOURCE_URL}/api/data/${DATAVERSE_API_VERSION}/${entitySet}(${id})?$select=${select}`; + const response = await client.get(url, AadHttpClient.configurations.v1); + if (!response.ok) { + let errBody = ''; + try { + errBody = await response.text(); + } catch { + // тело ошибки прочитать не удалось — оставляем пустым + } + throw new Error(`Dataverse GET ${entitySet}(${id}): ${response.status} — ${errBody}`); + } + const body = await response.json(); + const value = body[select]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * Проект-получатель («Куда») по строке-источнику ТМЦ. Цепочка: + * «Строка заявки» (cree9_demandline, id = positionId из URL) + * → колонка «ID позиции ЛЗК» (GUID строки lzk position, хранится ТЕКСТОМ) + * → «lzk position» → lookup на родительскую «LZK» + * → «LZK» → колонка «project id» (= oid проекта из справочника /Project). + * + * Каждое звено — отдельный запрос: текстовую ссылку нельзя $expand'нуть, а + * lookup читаем _value-аннотацией, не полагаясь на имя навигационного свойства. + * + * undefined — звено есть, но значение не заполнено (у строки нет позиции ЛЗК, + * у ЛЗК нет проекта). Исключение — запрос упал (нет записи, нет прав). + */ +export async function resolveLzkProjectId(context: WebPartContext, positionId: string): Promise { + const client = await context.aadHttpClientFactory.getClient(DATAVERSE_RESOURCE_URL); + + const lzkPositionId = await getFieldValue( + client, + DATAVERSE_DEMANDLINE_ENTITY_SET, + positionId, + DATAVERSE_DEMANDLINE_LZKPOSITION_FIELD + ); + if (!lzkPositionId) { + warnEmpty(DATAVERSE_DEMANDLINE_ENTITY_SET, positionId, DATAVERSE_DEMANDLINE_LZKPOSITION_FIELD); + return undefined; + } + + const lzkId = await getFieldValue( + client, + DATAVERSE_LZKPOSITION_ENTITY_SET, + lzkPositionId, + `_${DATAVERSE_LZKPOSITION_LZK_LOOKUP_FIELD}_value` + ); + if (!lzkId) { + warnEmpty(DATAVERSE_LZKPOSITION_ENTITY_SET, lzkPositionId, DATAVERSE_LZKPOSITION_LZK_LOOKUP_FIELD); + return undefined; + } + + const projectId = await getFieldValue(client, DATAVERSE_LZK_ENTITY_SET, lzkId, DATAVERSE_LZK_PROJECT_FIELD); + if (!projectId) { + warnEmpty(DATAVERSE_LZK_ENTITY_SET, lzkId, DATAVERSE_LZK_PROJECT_FIELD); + } + return projectId; +} + +/** + * Пустое звено — не ошибка (данные просто не заполнены), но без сообщения + * невозможно понять, почему «Куда» не предзаполнилось. + */ +function warnEmpty(entitySet: string, id: string, field: string): void { + // eslint-disable-next-line no-console + console.warn(`«Куда» не предзаполнено: у ${entitySet}(${id}) пустое поле ${field}.`); +} diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx index f8a64d9..97686f6 100644 --- a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx @@ -19,9 +19,19 @@ import { isCurrentUserTester } from './testerGroupAccess'; */ const SUBMISSION_FROZEN = true; +/** + * Формы, для которых заморозка снята досрочно (для всех пользователей, не + * только тестировщиков) — id передаётся через проп formId. Пример: заявка 1С + * разморожена 2026-09-22 по просьбе Тимура Городецкого, остальные формы + * остаются замороженными. + */ +const UNFROZEN_FORMS = new Set(['ticket1c']); + export interface ISubmitActionsProps { submit: IRequestSubmit; isValid: boolean; + /** Идентификатор формы — используется, чтобы точечно снимать заморозку (см. UNFROZEN_FORMS). */ + formId?: string; /** * Доп. условие для кнопки «Отправить», когда у отправки более строгие * требования, чем у черновика (например, поля обязательные только при @@ -46,6 +56,7 @@ export const SubmitActions: React.FC = ({ submit, isValid, submitValid, + formId, readOnly, onCancel, onSaveDraft, @@ -53,12 +64,13 @@ export const SubmitActions: React.FC = ({ submitLabel = 'Отправить на согласование', }) => { const { saving, kind } = submit; + const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId); // false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не // мигать разблокированными кнопками до подтверждения, что это тестировщик. const [isTester, setIsTester] = React.useState(false); React.useEffect(() => { - if (!SUBMISSION_FROZEN) return; + if (!SUBMISSION_FROZEN || unfrozenForm) return; let alive = true; isCurrentUserTester() .then((v) => { @@ -68,9 +80,9 @@ export const SubmitActions: React.FC = ({ return () => { alive = false; }; - }, []); + }, [unfrozenForm]); - const frozen = SUBMISSION_FROZEN && !isTester; + const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester; const locked = saving || readOnly || frozen; const canSubmit = submitValid ?? isValid;