From 5fd8bfe453132d677ac28a34fedd2a68749df81e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 16:39:06 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20=D0=BD=D0=B5=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=20=D0=B2=20=D0=AD=D0=94=D0=9E,=20=D0=B5=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B2=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B5=20=D0=B5=D1=81=D1=82=D1=8C=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отправка срабатывала только на статус «Согласовано», а при исполнителе в маршруте последний согласующий переводит заявку сразу в «Выполнение» — запрос в ЭДО не шёл вообще. Теперь отправка срабатывает на решение, завершившее фазу согласования (isApprovalPhaseCompleted: «На согласовании» → «Согласовано»/«Выполнение»/«Закрыта»); решение исполнителя повторно её не запускает. В зелёное сообщение «Решение сохранено» дописывается «Документы отправлены на подписание в ЭДО…» (onDecided панели решения может вернуть строку). Заодно пять чужих предупреждений линтера: eqeqeq в SubmitActions, явные типы возврата в approverOrgInfo и useSkudRoute, зависимость эффекта в ServiceRequestView, null → undefined в ячейках календаря СКУД. Co-Authored-By: Claude Sonnet 5.5 --- .../ServiceRequest/ServiceRequestView.tsx | 8 +++-- .../SkudExplanation/skud/skudDays.test.ts | 4 +-- .../forms/SkudExplanation/skud/skudDays.ts | 8 ++--- .../forms/SkudExplanation/useSkudRoute.ts | 2 +- .../requestPortal/forms/Vacation/README.md | 13 ++++--- .../forms/Vacation/VacationView.tsx | 32 ++++++++++------- .../shared/approval/approverOrgInfo.ts | 2 +- .../shared/approval/models/statuses.test.ts | 36 +++++++++++++++++++ .../shared/approval/models/statuses.ts | 15 ++++++++ .../shared/approval/ui/DecisionPanel.tsx | 15 ++++++-- .../shared/ui/SubmitActions/SubmitActions.tsx | 2 +- 11 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 66cc213..120d91e 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -29,6 +29,7 @@ import { } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { useStableCallback } from '../../shared/useStableCallback'; import { approvalListTitle, approvalListTitleForListId, @@ -57,6 +58,9 @@ interface IServiceRequestViewProps { // Заявка «Заявка на услуги» только для просмотра (режим согласующего). const ServiceRequestView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта + // загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -95,7 +99,7 @@ const ServiceRequestView: React.FC = ({ context, itemI setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -110,7 +114,7 @@ const ServiceRequestView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection). // Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров, diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts index 6a95c96..ec0dd56 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts @@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => { }); it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => { - expect(cells[0]).toBeNull(); - expect(cells[1]).toBeNull(); + expect(cells[0]).toBeUndefined(); + expect(cells[1]).toBeUndefined(); expect(cells[2]?.day).toBe(1); }); }); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts index 8057192..051a6bd 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts @@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map): number { return total; } -/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */ -export function buildMonthCells(year: number, month: number, byDay: Map): (IDayInfo | null)[] { +/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */ +export function buildMonthCells(year: number, month: number, byDay: Map): (IDayInfo | undefined)[] { const daysInMonth = new Date(year, month + 1, 0).getDate(); const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK; - const cells: (IDayInfo | null)[] = []; - for (let i = 0; i < firstColumn; i += 1) cells.push(null); + const cells: (IDayInfo | undefined)[] = []; + for (let i = 0; i < firstColumn; i += 1) cells.push(undefined); for (let d = 1; d <= daysInMonth; d += 1) { const key = toDateKey(year, month, d); cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' }); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts index fd3d7d3..640c858 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts @@ -33,7 +33,7 @@ interface IRouteSource { reloadSeq?: number; } -const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] => +const byPhase = (execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) => (rows) => rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution); /** diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index e0f4c63..33e4cd6 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -8,8 +8,9 @@ ## Маршрут - Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation», `listId` «/sites/requests/Lists/List5», section «Кадры». -- Фазы: только согласование. Фазы исполнения нет — после согласования заявка - просто получает статус «Согласовано» (никакого Planner, в отличие от Ticket1C). +- Фазы: согласование, а при наличии исполнителей в маршруте (бывает: тип заявки может их + содержать) — ещё и исполнение. Без исполнителей после согласования заявка получает статус + «Согласовано», с исполнителем — сразу «Выполнение». Planner не используется (в отличие от Ticket1C). - **Матрица редактируема заявителем** при новой заявке / черновике / возврате (как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`. Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого @@ -29,8 +30,12 @@ - **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`. ## Подписание в ЭДО -После того как все согласующие согласовали (статус заявки становится «Согласовано»), из браузера -**последнего согласующего** (`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО: +После того как все согласующие согласовали, из браузера **последнего согласующего** +(`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО. Срабатывает на решение, которое +завершило фазу согласования (`isApprovalPhaseCompleted`: «На согласовании» → «Согласовано» **или** +«Выполнение»), а не только на «Согласовано»: если в маршруте есть исполнитель, заявка сразу становится +«Выполнение». Решение исполнителя повторно отправку не запускает. В зелёном сообщении «Решение сохранено» +появляется строка «Документы отправлены на подписание в ЭДО…»: - **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан. - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 129c33c..77fe59a 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -16,7 +16,7 @@ import { notifyActiveStep, useRequestTypeId } from '../../shared/approval'; -import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; +import { isApprovalPhaseCompleted } from '../../shared/approval/models/statuses'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -145,20 +145,27 @@ const VacationView: React.FC = ({ context, itemId, formKey, // поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно // кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО // отклонит как дубль — признака «уже отправлено» в заявке нет. - const sendToSigning = (): Promise => { + // Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче — + // ничего (ошибку и кнопку повтора показывает блок ниже). + const sendToSigning = (): Promise => { setSigningError(''); setSigningResult(undefined); setSigningBusy(true); return sendVacationForSigning(context, { data, employeeName, itemId, code }) - .then((result) => { + .then((result): string | undefined => { setSigningResult(result); + return `Документы отправлены на подписание в ЭДО (${result.sent.join(', ')}): ссылка для подписания ушла на ${data.Employee}.`; }) - .catch((e) => { + .catch((e): string | undefined => { setSigningError( `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` ); + return undefined; }) - .then(() => setSigningBusy(false)); + .then((note) => { + setSigningBusy(false); + return note; + }); }; return ( @@ -198,11 +205,6 @@ const VacationView: React.FC = ({ context, itemId, formKey,
{signingBusy && Отправляем заявление на подпись в ЭДО…} - {!signingBusy && signingResult && ( - - {`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`} - - )} {!signingBusy && signingResult && signingResult.skipped.length > 0 && ( {`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`} @@ -224,10 +226,14 @@ const VacationView: React.FC = ({ context, itemId, formKey, setStatus(newStatus); setDecisionSeq((n) => n + 1); notify(); - // «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет). + // Это решение завершило согласование: подписывать пора, а не когда заявка «Согласовано». + // Если в маршруте есть исполнитель, последний согласующий переводит заявку сразу в + // «Выполнение», и проверка только на «Согласовано» пропускала такие заявки. Предыдущий + // статус (`status` — до этого решения) отсекает решения исполнителя. // Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение - // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись. - return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined; + // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись; а его + // результат панель допишет к зелёному сообщению. + return isApprovalPhaseCompleted(status, newStatus) ? sendToSigning() : undefined; }} onDelegated={() => { setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts b/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts index b97d6d7..b6c2485 100644 --- a/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts +++ b/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts @@ -17,7 +17,7 @@ export interface IApproverOrgInfo { * email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations * отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы. */ -export function resolveApproverOrgInfo(context: WebPartContext) { +export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise { return async (email: string): Promise => { const records = await getEmployeeOrganizations(context, email); const first = records[0]; diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts index d9f049f..638231d 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts @@ -6,6 +6,7 @@ import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, initialSubmitStatus, + isApprovalPhaseCompleted, isEditableStatus, isSubmittedStatus, } from './statuses'; @@ -54,6 +55,41 @@ describe('initialSubmitStatus', () => { // возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки // маршрута получают недопустимый статус, а активации не происходит вовсе — // именно так заявка без согласующих перестала попадать в очередь исполнения. +describe('isApprovalPhaseCompleted', () => { + it('последний согласующий без исполнителей: «На согласовании» → «Согласовано»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.approved)).toBe(true); + }); + + // Тот самый случай: в маршруте есть исполнитель, и заявка сразу становится «Выполнение». + it('последний согласующий при наличии исполнителя: «На согласовании» → «Выполнение»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.execution)).toBe(true); + }); + + it('согласование завершилось, исполнители уже отработали: → «Закрыта»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.closed)).toBe(true); + }); + + it('согласуют не последние: заявка остаётся «На согласовании»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.onApproval)).toBe(false); + }); + + it('возврат и отклонение не завершают согласование', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.returned)).toBe(false); + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.rejected)).toBe(false); + }); + + // Решение исполнителя не должно второй раз запускать то, что делается по итогам согласования. + it('решение исполнителя — не завершение согласования', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.execution)).toBe(false); + expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.closed)).toBe(false); + }); + + it('предыдущий статус неизвестен — не гадаем', () => { + expect(isApprovalPhaseCompleted(undefined, REQUEST_STATUS.approved)).toBe(false); + expect(isApprovalPhaseCompleted('', REQUEST_STATUS.execution)).toBe(false); + }); +}); + describe('isSubmittedStatus', () => { it('любой результат initialSubmitStatus считается отправкой', () => { [initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) => diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index dc00973..a3e38b0 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -61,6 +61,21 @@ export function initialSubmitStatus(approversCount: number, executorsCount: numb return REQUEST_STATUS.approved; } +/** + * Это решение завершило фазу СОГЛАСОВАНИЯ: заявка была «На согласовании», а стала «Согласовано», + * «Выполнение» или «Закрыта». + * + * Статус «Согласовано» бывает только у маршрута без исполнителей. Если исполнитель в маршруте + * есть, последний согласующий переводит заявку сразу в «Выполнение», и проверять только + * «Согласовано» — значит пропускать такие заявки. Сравнение с ПРЕДЫДУЩИМ статусом нужно затем, + * чтобы решение исполнителя («Выполнение» → «Выполнение»/«Закрыта») не принималось за + * завершение согласования и побочное действие не срабатывало второй раз. + */ +export function isApprovalPhaseCompleted(previous: string | undefined, next: string): boolean { + if (previous !== REQUEST_STATUS.onApproval) return false; + return next === REQUEST_STATUS.approved || next === REQUEST_STATUS.execution || next === REQUEST_STATUS.closed; +} + /** * Статус заявки означает «отправлена» (в отличие от черновика)? * diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx index a85387e..95d04a4 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -23,8 +23,12 @@ interface IDecisionPanelProps { * Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту * уже применено на сервере; сообщить о проблеме побочного действия должен сам * обработчик (у него для этого есть своя область экрана). + * + * Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…» + * (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно + * не удалось — не возвращайте ничего, сообщение останется прежним. */ - onDecided?: (status: string) => void | Promise; + onDecided?: (status: string) => void | string | undefined | Promise; /** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные * согласующим). Если бросит — решение не применяется, показывается ошибка. * decision — какое решение применяется (проверки, нужные только при @@ -66,6 +70,8 @@ export const DecisionPanel: React.FC = ({ const [comment, setComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [resultStatus, setResultStatus] = useState(undefined); + // Что обработчик onDecided велел дописать к сообщению об успехе. + const [resultNote, setResultNote] = useState(undefined); const [delegated, setDelegated] = useState(0); const [showDelegate, setShowDelegate] = useState(false); // Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе @@ -119,13 +125,16 @@ export const DecisionPanel: React.FC = ({ // отработал, кнопки остаются заблокированными (submitting), и пользователь не // видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не // влияет — оно уже применено, поэтому сообщение показываем всё равно. + let note: string | undefined; if (onDecided) { try { - await onDecided(status); + const returned = await onDecided(status); + note = typeof returned === 'string' && returned ? returned : undefined; } catch (e) { console.error('Побочное действие после решения не выполнилось', e); } } + setResultNote(note); setResultStatus(status); setSubmitting(false); } catch (e) { @@ -205,7 +214,7 @@ export const DecisionPanel: React.FC = ({
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: « - {resultStatus}». + {resultStatus}».{resultNote ? ` ${resultNote}` : ''}
); diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx index 653ca17..484537a 100644 --- a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx @@ -78,7 +78,7 @@ export const SubmitActions: React.FC = ({ onDelete, }) => { const { saving, kind } = submit; - const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId); + const unfrozenForm = formId !== undefined && UNFROZEN_FORMS.has(formId); // false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не // мигать разблокированными кнопками до подтверждения, что это тестировщик. const [isTester, setIsTester] = React.useState(false);