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 IAttachmentInfo
} from '../../shared/attachments/attachments.service'; } from '../../shared/attachments/attachments.service';
import { effectiveUserEmail } from '../../shared/impersonation'; import { effectiveUserEmail } from '../../shared/impersonation';
import { getManagerEmail, getEmployeeOrganizations } from '../../shared/dictionary';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { IRequestFormProps } from '../../models/types'; import { IRequestFormProps } from '../../models/types';
import { import {
@@ -44,33 +45,13 @@ import {
dataListTitleForListId dataListTitleForListId
} from '../../shared/listTitles'; } from '../../shared/listTitles';
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; 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 { loadTicketRequest } from './load';
import { PlannerTicketService } from './services/plannerTicket.service';
import { getApplicantProfile } from './services/profile.service'; import { getApplicantProfile } from './services/profile.service';
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
import { RequestSection } from './sections/RequestSection'; import { RequestSection } from './sections/RequestSection';
import { ContactSection } from './sections/ContactSection'; import { ContactSection } from './sections/ContactSection';
import { useStableCallback } from '../../shared/useStableCallback'; 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> = ({ const Ticket1CForm: React.FC<IRequestFormProps> = ({
requestType, requestType,
context, context,
@@ -145,6 +126,23 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
})); }));
}) })
.catch(() => undefined); .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 () => { return () => {
alive = false; alive = false;
}; };
@@ -229,14 +227,12 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
if (!rtId) { if (!rtId) {
throw new Error('Тип заявки не определён — обновите страницу и повторите.'); throw new Error('Тип заявки не определён — обновите страницу и повторите.');
} }
// Пустой маршрут — согласование не требуется по правилам этого вида работ. // Маршрут запрашивается для ЛЮБОГО вида работ и используется как есть —
const hasApprovers = matrix.rows.length > 0; // без авто-approve, если админ ещё не настроил шаги для конкретного вида
const finalStatus = // работ (раньше в этом случае заявка сразу становилась «Согласовано» в
intendedStatus === REQUEST_STATUS.draft // обход согласования; теперь это просто «На согласовании» с пустой
? REQUEST_STATUS.draft // матрицей — сигнал администратору маршрутов донастроить условие).
: hasApprovers const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval;
? REQUEST_STATUS.onApproval
: REQUEST_STATUS.approved;
// requestId уже задан → редактируем существующую заявку, пишем в ту же // requestId уже задан → редактируем существующую заявку, пишем в ту же
// партицию (editListId). Иначе — создаём новую, и партицию для записи // партицию (editListId). Иначе — создаём новую, и партицию для записи
@@ -260,18 +256,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
setRequestCode(code); setRequestCode(code);
} }
let attachments = existingAttachments;
if (files.length > 0) { if (files.length > 0) {
await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files); await uploadAttachments(writeListId || dataListTitle(FORM_ID), id, files);
setFiles([]); setFiles([]);
attachments = await getAttachments(writeListId || dataListTitle(FORM_ID), id); setExistingAttachments(await getAttachments(writeListId || dataListTitle(FORM_ID), id));
setExistingAttachments(attachments);
} }
if (onSaved) { if (onSaved) {
onSaved(); onSaved();
} }
// Передача в Planner теперь происходит только после реального решения
// согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда
// отправляется на согласование, минуя Planner напрямую.
if (finalStatus === REQUEST_STATUS.onApproval) { if (finalStatus === REQUEST_STATUS.onApproval) {
// Имена списков — той же партиции, куда фактически записали (writeListId), // Имена списков — той же партиции, куда фактически записали (writeListId),
// не обязательно текущей на момент рендера формы: поток ищет строку // не обязательно текущей на момент рендера формы: поток ищет строку
@@ -281,24 +278,13 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID),
approvalListName: writeListId ? approvalListTitleForListId(FORM_ID, writeListId) : approvalListTitle(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);
} }
}; };
// На отправку маршрут должен быть либо пустым (согласование не нужно), либо // На отправку матрица не обязана быть непустой (админ мог ещё не настроить
// полностью укомплектован. Ошибку резолва маршрута (напр. тип заявки ещё не // шаги для этого вида работ — заявка всё равно уходит «На согласовании», см.
// настроен в админке маршрутов) не путаем с «согласование не требуется» — // save() выше), но если шаг ЕСТЬ, он должен быть назначен на конкретного
// иначе заявка молча уйдёт в Planner в обход согласования. // человека — иначе решать по нему будет некому.
const validateSubmit = (): string | undefined => { const validateSubmit = (): string | undefined => {
const formError = validate(); const formError = validate();
if (formError) return formError; if (formError) return formError;
@@ -374,7 +360,7 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
<ApprovalMatrix <ApprovalMatrix
editor={matrix} editor={matrix}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
emptyText="Согласование не требуется для этого вида работ." emptyText="Маршрут для этого вида работ пока не настроен — обратитесь к администратору маршрутов согласования."
/> />
<SubmitActions <SubmitActions
@@ -4,7 +4,7 @@ import { FORM_ID, IFormData, EMPTY_DATA } from './model';
// TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName). // TaskName пишется/читается через системную колонку Title (см. provisioning.json/titleColumnName).
const SCALAR_FIELDS: (keyof IFormData)[] = [ 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 { export interface ILoadedRequest {
@@ -22,6 +22,8 @@ export interface IFormData {
Phone: string; Phone: string;
Department: string; Department: string;
Position: string; Position: string;
/** Непосредственный руководитель заявителя — предзаполняется через getManagerEmail, редактируем. */
Manager: string;
Question: string; Question: string;
} }
@@ -45,5 +47,6 @@ export const EMPTY_DATA: IFormData = {
Phone: '', Phone: '',
Department: '', Department: '',
Position: '', Position: '',
Manager: '',
Question: '', Question: '',
}; };
@@ -46,6 +46,7 @@
{ "internalName": "Phone", "title": "Телефон", "type": "Text" }, { "internalName": "Phone", "title": "Телефон", "type": "Text" },
{ "internalName": "Department", "title": "Отдел", "type": "Text" }, { "internalName": "Department", "title": "Отдел", "type": "Text" },
{ "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "Position", "title": "Должность", "type": "Text" },
{ "internalName": "Manager", "title": "Руководитель", "type": "Text" },
{ "internalName": "Question", "title": "Вопрос", "type": "Note", "required": true }, { "internalName": "Question", "title": "Вопрос", "type": "Note", "required": true },
@@ -19,5 +19,8 @@ export const ContactSection: React.FC<ISectionProps> = ({ data, set }) => (
<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)} />
</FormField> </FormField>
<FormField label="Руководитель" htmlFor="Manager">
<TextInput id="Manager" value={data.Manager} onChange={(v) => set('Manager', v)} />
</FormField>
</CollapseSection> </CollapseSection>
); );