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
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user