feat: предзаполнение «Куда» в ТМЦ, разморозка 1С, readOnly «Отдел»
Tests / frontend-test (push) Successful in 1m16s
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:
@@ -406,6 +406,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
<SubmitActions
|
<SubmitActions
|
||||||
submit={submit}
|
submit={submit}
|
||||||
isValid={isValid}
|
isValid={isValid}
|
||||||
|
formId="ticket1c"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onSaveDraft={handleSaveDraft}
|
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)} />
|
<PhoneInput id="Phone" value={data.Phone} onChange={(v) => set('Phone', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Отдел" htmlFor="Department">
|
<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>
|
||||||
<FormField label="Должность" htmlFor="Position">
|
<FormField label="Должность" htmlFor="Position">
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ import {
|
|||||||
getEmployeeOrganizations,
|
getEmployeeOrganizations,
|
||||||
formatEmployeeOrganizations,
|
formatEmployeeOrganizations,
|
||||||
getNomenclature,
|
getNomenclature,
|
||||||
|
getProjects,
|
||||||
INomenclatureRow
|
INomenclatureRow
|
||||||
} from '../../shared/dictionary';
|
} from '../../shared/dictionary';
|
||||||
import { ILineItem } from '../../shared/ui';
|
import { ILineItem } from '../../shared/ui';
|
||||||
import { requestLinkUrl } from '../../shared/routing';
|
import { requestLinkUrl } from '../../shared/routing';
|
||||||
import { writeDemandLinePortalLink } from '../../shared/dataverse/demandLine.service';
|
import { writeDemandLinePortalLink } from '../../shared/dataverse/demandLine.service';
|
||||||
|
import { resolveLzkProjectId } from '../../shared/dataverse/lzkPosition.service';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import { IRequestFormProps } from '../../models/types';
|
import { IRequestFormProps } from '../../models/types';
|
||||||
import {
|
import {
|
||||||
@@ -172,6 +174,46 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
};
|
};
|
||||||
}, [editId, nomenclatureId, positionId, storeName, context, setSubmitError]);
|
}, [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 — здесь
|
// в Vacation.handleEmployeeChange, но источник тот же Employee/ByEmail — здесь
|
||||||
// дополнительно берём ФИО, т.к. TMC хранит его отдельным полем AuthorName).
|
// дополнительно берём ФИО, т.к. TMC хранит его отдельным полем AuthorName).
|
||||||
|
|||||||
@@ -86,3 +86,24 @@ export const DATAVERSE_DEMANDLINE_ENTITY_SET = 'cree9_demandlines';
|
|||||||
* как у остальных колонок таблицы — так сгенерировалось при создании колонки.
|
* как у остальных колонок таблицы — так сгенерировалось при создании колонки.
|
||||||
*/
|
*/
|
||||||
export const DATAVERSE_DEMANDLINE_LINK_FIELD = 'cr498_portalrequesturl';
|
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;
|
const SUBMISSION_FROZEN = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Формы, для которых заморозка снята досрочно (для всех пользователей, не
|
||||||
|
* только тестировщиков) — id передаётся через проп formId. Пример: заявка 1С
|
||||||
|
* разморожена 2026-09-22 по просьбе Тимура Городецкого, остальные формы
|
||||||
|
* остаются замороженными.
|
||||||
|
*/
|
||||||
|
const UNFROZEN_FORMS = new Set<string>(['ticket1c']);
|
||||||
|
|
||||||
export interface ISubmitActionsProps {
|
export interface ISubmitActionsProps {
|
||||||
submit: IRequestSubmit;
|
submit: IRequestSubmit;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
|
/** Идентификатор формы — используется, чтобы точечно снимать заморозку (см. UNFROZEN_FORMS). */
|
||||||
|
formId?: string;
|
||||||
/**
|
/**
|
||||||
* Доп. условие для кнопки «Отправить», когда у отправки более строгие
|
* Доп. условие для кнопки «Отправить», когда у отправки более строгие
|
||||||
* требования, чем у черновика (например, поля обязательные только при
|
* требования, чем у черновика (например, поля обязательные только при
|
||||||
@@ -46,6 +56,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
|||||||
submit,
|
submit,
|
||||||
isValid,
|
isValid,
|
||||||
submitValid,
|
submitValid,
|
||||||
|
formId,
|
||||||
readOnly,
|
readOnly,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
@@ -53,12 +64,13 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
|||||||
submitLabel = 'Отправить на согласование',
|
submitLabel = 'Отправить на согласование',
|
||||||
}) => {
|
}) => {
|
||||||
const { saving, kind } = submit;
|
const { saving, kind } = submit;
|
||||||
|
const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId);
|
||||||
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
|
||||||
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
// мигать разблокированными кнопками до подтверждения, что это тестировщик.
|
||||||
const [isTester, setIsTester] = React.useState<boolean>(false);
|
const [isTester, setIsTester] = React.useState<boolean>(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!SUBMISSION_FROZEN) return;
|
if (!SUBMISSION_FROZEN || unfrozenForm) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
isCurrentUserTester()
|
isCurrentUserTester()
|
||||||
.then((v) => {
|
.then((v) => {
|
||||||
@@ -68,9 +80,9 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [unfrozenForm]);
|
||||||
|
|
||||||
const frozen = SUBMISSION_FROZEN && !isTester;
|
const frozen = SUBMISSION_FROZEN && !unfrozenForm && !isTester;
|
||||||
const locked = saving || readOnly || frozen;
|
const locked = saving || readOnly || frozen;
|
||||||
const canSubmit = submitValid ?? isValid;
|
const canSubmit = submitValid ?? isValid;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user