fix: заявка в 1С всегда уходит на согласование + добавить поле «Руководитель»
Tests / frontend-test (push) Successful in 1m20s

This commit is contained in:
Тимур Городецкий
2026-09-11 12:20:31 +05:00
parent a999d45133
commit 564921d56d
5 changed files with 42 additions and 49 deletions
@@ -35,6 +35,7 @@ import {
IAttachmentInfo
} from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation';
import { getManagerEmail, getEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types';
import {
@@ -44,33 +45,13 @@ import {
dataListTitleForListId
} from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { saveTicketRequest, setPlannerTaskId, ticket1cApprovalService } from './submit';
import { saveTicketRequest, ticket1cApprovalService } from './submit';
import { loadTicketRequest } from './load';
import { PlannerTicketService } from './services/plannerTicket.service';
import { getApplicantProfile } from './services/profile.service';
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback';
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
function buildPlannerNotes(data: IFormData, code: string): string {
const lines: string[] = [];
if (code) lines.push(`Код заявки: ${code}`);
lines.push(`Организация: ${data.Organization}`);
lines.push(`ФИО: ${data.FIO}`);
lines.push(`Email: ${data.Email}`);
lines.push(`Телефон: ${data.Phone}`);
lines.push(`Отдел: ${data.Department}`);
lines.push(`Должность: ${data.Position}`);
lines.push(`База 1С: ${data.DB1C}`);
lines.push(`Виды работ: ${data.WorkType}`);
lines.push('');
lines.push('Вопрос:');
lines.push(data.Question);
return lines.join('\n');
}
const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestType,
context,
@@ -145,6 +126,23 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
}));
})
.catch(() => undefined);
// Независимо от профиля Graph — непосредственный руководитель (тот же
// источник, что у Dismissal/Vacation, см. shared/dictionary/employeeLookup).
// Ф.И.О. руководителя своего поля не возвращает — доп. запрос в тот же
// Employee/ByEmail (см. getEmployeeOrganizations), которым форма уже
// резолвит ФИО/организацию заявителя.
getManagerEmail(context, context.pageContext.user.email, 'chief')
.then((email) => {
if (!alive || !email) return;
setData((prev) => ({ ...prev, Manager: prev.Manager || email }));
getEmployeeOrganizations(context, email)
.then((records) => {
const fio = records[0]?.fio;
if (alive && fio) setData((prev) => ({ ...prev, Manager: `${fio} · ${email}` }));
})
.catch(() => undefined);
})
.catch(() => undefined);
return () => {
alive = false;
};
@@ -229,14 +227,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.');
}
// Пустой маршрут — согласование не требуется по правилам этого вида работ.
const hasApprovers = matrix.rows.length > 0;
const finalStatus =
intendedStatus === REQUEST_STATUS.draft
? REQUEST_STATUS.draft
: hasApprovers
? REQUEST_STATUS.onApproval
: REQUEST_STATUS.approved;
// Маршрут запрашивается для ЛЮБОГО вида работ и используется как есть —
// без авто-approve, если админ ещё не настроил шаги для конкретного вида
// работ (раньше в этом случае заявка сразу становилась «Согласовано» в
// обход согласования; теперь это просто «На согласовании» с пустой
// матрицей — сигнал администратору маршрутов донастроить условие).
const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval;
// requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи
@@ -260,18 +256,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
setRequestCode(code);
}
let attachments = existingAttachments;
if (files.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
setFiles([]);
attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id);
setExistingAttachments(attachments);
setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
}
if (onSaved) {
onSaved();
}
// Передача в Planner теперь происходит только после реального решения
// согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда
// отправляется на согласование, минуя Planner напрямую.
if (finalStatus === REQUEST_STATUS.onApproval) {
// Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку
@@ -281,24 +278,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(FORM_ID)
});
} else if (finalStatus === REQUEST_STATUS.approved) {
// Согласование не требуется для этого вида работ — сразу передаём в отдел 1С.
const taskId = await new PlannerTicketService(context).createTask({
planId: PLANNER_PLAN_ID,
bucketName: PLANNER_BUCKET_NAME,
title: data.TaskName,
notes: buildPlannerNotes(data, code || String(id)),
attachments,
webAbsoluteUrl: context.pageContext.web.absoluteUrl
});
await setPlannerTaskId(id, taskId, writeListId);
}
};
// На отправку маршрут должен быть либо пустым (согласование не нужно), либо
// полностью укомплектован. Ошибку резолва маршрута (напр. тип заявки ещё не
// настроен в админке маршрутов) не путаем с «согласование не требуется» —
// иначе заявка молча уйдёт в Planner в обход согласования.
// На отправку матрица не обязана быть непустой (админ мог ещё не настроить
// шаги для этого вида работ — заявка всё равно уходит «На согласовании», см.
// save() выше), но если шаг ЕСТЬ, он должен быть назначен на конкретного
// человека — иначе решать по нему будет некому.
const validateSubmit = (): string | undefined => {
const formError = validate();
if (formError) return formError;
@@ -374,7 +360,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix
editor={matrix}
search={(q) => approval.searchUsers(q)}
emptyText="Согласование не требуется для этого вида работ."
emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования."
/>
<SubmitActions
@@ -4,7 +4,7 @@ import { FORM_ID, IFormData, EMPTY_DATA } from './model';
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
const SCALAR_FIELDS: (keyof IFormData)[] = [
'WorkType', 'Organization', 'DB1C', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Question',
'WorkType', 'Organization', 'DB1C', 'FIO', 'Email', 'Phone', 'Department', 'Position', 'Manager', 'Question',
];
export interface ILoadedRequest {
@@ -22,6 +22,8 @@ export interface IFormData {
Phone: string;
Department: string;
Position: string;
/** Непосредственный руководитель заявителя — предзаполняется через getManagerEmail, редактируем. */
Manager: string;
Question: string;
}
@@ -45,5 +47,6 @@ export const EMPTY_DATA: IFormData = {
Phone: '',
Department: '',
Position: '',
Manager: '',
Question: '',
};
@@ -46,6 +46,7 @@
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "Department", "title": "Отдел", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Manager", "title": "Руководитель", "type": "Text" },
{ "internalName": "Question", "title": "Вопрос", "type": "Note", "required": true },
@@ -19,5 +19,8 @@ export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
<FormField label="Должность" htmlFor="Position">
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
</FormField>
<FormField label="Руководитель" htmlFor="Manager">
<TextInput id="Manager" value={data.Manager} onChange={(v) => set('Manager', v)} />
</FormField>
</CollapseSection>
);