feat: предзаполнение «Куда» в ТМЦ, разморозка 1С, readOnly «Отдел»
Tests / frontend-test (push) Successful in 1m16s

- СЗ на перемещение ТМЦ: поле «Куда» предзаполняется проектом-получателем
  по строке-источнику 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 <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-23 11:07:54 +05:00
parent f6dcf349e9
commit 98226e4753
6 changed files with 173 additions and 4 deletions
@@ -406,6 +406,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<SubmitActions
submit={submit}
isValid={isValid}
formId="ticket1c"
readOnly={readOnly}
onCancel={onCancel}
onSaveDraft={handleSaveDraft}
@@ -14,7 +14,7 @@ export const ContactSection: React.FC<ISectionProps> = ({ data, set, context })
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
</FormField>
<FormField label="Отдел" htmlFor="Department">
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} />
<TextInput id="Department" value={data.Department} onChange={(v) => set('Department', v)} readOnly />
</FormField>
<FormField label="Должность" htmlFor="Position">
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
@@ -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<IRequestFormProps> = ({
};
}, [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).
@@ -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';
@@ -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<string | undefined> {
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<string | undefined> {
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}.`);
}
@@ -19,9 +19,19 @@ import { isCurrentUserTester } from './testerGroupAccess';
*/
const SUBMISSION_FROZEN = true;
/**
* Формы, для которых заморозка снята досрочно (для всех пользователей, не
* только тестировщиков) — id передаётся через проп formId. Пример: заявка 1С
* разморожена 2026-09-22 по просьбе Тимура Городецкого, остальные формы
* остаются замороженными.
*/
const UNFROZEN_FORMS = new Set<string>(['ticket1c']);
export interface ISubmitActionsProps {
submit: IRequestSubmit;
isValid: boolean;
/** Идентификатор формы — используется, чтобы точечно снимать заморозку (см. UNFROZEN_FORMS). */
formId?: string;
/**
* Доп. условие для кнопки «Отправить», когда у отправки более строгие
* требования, чем у черновика (например, поля обязательные только при
@@ -46,6 +56,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submit,
isValid,
submitValid,
formId,
readOnly,
onCancel,
onSaveDraft,
@@ -53,12 +64,13 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
submitLabel = 'Отправить на согласование',
}) => {
const { saving, kind } = submit;
const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId);
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
const [isTester, setIsTester] = React.useState<boolean>(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<ISubmitActionsProps> = ({
return () => {
alive = false;
};
}, []);
}, [unfrozenForm]);
const frozen = SUBMISSION_FROZEN && !isTester;
const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester;
const locked = saving || readOnly || frozen;
const canSubmit = submitValid ?? isValid;