fix: заявление на отпуск не уходило в ЭДО, если в маршруте есть исполнитель

Отправка срабатывала только на статус «Согласовано», а при исполнителе в маршруте последний
согласующий переводит заявку сразу в «Выполнение» — запрос в ЭДО не шёл вообще. Теперь отправка
срабатывает на решение, завершившее фазу согласования (isApprovalPhaseCompleted: «На согласовании»
→ «Согласовано»/«Выполнение»/«Закрыта»); решение исполнителя повторно её не запускает.

В зелёное сообщение «Решение сохранено» дописывается «Документы отправлены на подписание в ЭДО…»
(onDecided панели решения может вернуть строку).

Заодно пять чужих предупреждений линтера: eqeqeq в SubmitActions, явные типы возврата
в approverOrgInfo и useSkudRoute, зависимость эффекта в ServiceRequestView, null → undefined
в ячейках календаря СКУД.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-02 16:39:06 +05:00
parent cce4dfa4b3
commit 5fd8bfe453
11 changed files with 106 additions and 31 deletions
@@ -29,6 +29,7 @@ import {
} from '../../shared/approval'; } from '../../shared/approval';
import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { useStableCallback } from '../../shared/useStableCallback';
import { import {
approvalListTitle, approvalListTitle,
approvalListTitleForListId, approvalListTitleForListId,
@@ -57,6 +58,9 @@ interface IServiceRequestViewProps {
// Заявка «Заявка на услуги» только для просмотра (режим согласующего). // Заявка «Заявка на услуги» только для просмотра (режим согласующего).
const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => { const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
// загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA); const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>(''); const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>(''); const [code, setCode] = useState<string>('');
@@ -95,7 +99,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
setData(loaded.data); setData(loaded.data);
setStatus(loaded.status); setStatus(loaded.status);
setCode(loaded.code); setCode(loaded.code);
onCodeLoaded?.(loaded.code); notifyCode(loaded.code);
setAttachments(files); setAttachments(files);
setError(null); setError(null);
setLoading(false); setLoading(false);
@@ -110,7 +114,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ context, itemI
return () => { return () => {
alive = false; alive = false;
}; };
}, [itemId, listId]); }, [itemId, listId, notifyCode]);
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection). // Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров, // Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
@@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => {
}); });
it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => { it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => {
expect(cells[0]).toBeNull(); expect(cells[0]).toBeUndefined();
expect(cells[1]).toBeNull(); expect(cells[1]).toBeUndefined();
expect(cells[2]?.day).toBe(1); expect(cells[2]?.day).toBe(1);
}); });
}); });
@@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
return total; return total;
} }
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */ /** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] { export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | undefined)[] {
const daysInMonth = new Date(year, month + 1, 0).getDate(); const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK; const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK;
const cells: (IDayInfo | null)[] = []; const cells: (IDayInfo | undefined)[] = [];
for (let i = 0; i < firstColumn; i += 1) cells.push(null); for (let i = 0; i < firstColumn; i += 1) cells.push(undefined);
for (let d = 1; d <= daysInMonth; d += 1) { for (let d = 1; d <= daysInMonth; d += 1) {
const key = toDateKey(year, month, d); const key = toDateKey(year, month, d);
cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' }); cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' });
@@ -33,7 +33,7 @@ interface IRouteSource {
reloadSeq?: number; 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); rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution);
/** /**
@@ -8,8 +8,9 @@
## Маршрут ## Маршрут
- Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation», - Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation»,
`listId` «/sites/requests/Lists/List5», section «Кадры». `listId` «/sites/requests/Lists/List5», section «Кадры».
- Фазы: только согласование. Фазы исполнения нет — после согласования заявка - Фазы: согласование, а при наличии исполнителей в маршруте (бывает: тип заявки может их
просто получает статус «Согласовано» (никакого Planner, в отличие от Ticket1C). содержать) — ещё и исполнение. Без исполнителей после согласования заявка получает статус
«Согласовано», с исполнителем — сразу «Выполнение». Planner не используется (в отличие от Ticket1C).
- **Матрица редактируема заявителем** при новой заявке / черновике / возврате - **Матрица редактируема заявителем** при новой заявке / черновике / возврате
(как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`. (как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`.
Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого
@@ -29,8 +30,12 @@
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
## Подписание в ЭДО ## Подписание в ЭДО
После того как все согласующие согласовали (статус заявки становится «Согласовано»), из браузера После того как все согласующие согласовали, из браузера **последнего согласующего**
**последнего согласующего** (`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО: (`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО. Срабатывает на решение, которое
завершило фазу согласования (`isApprovalPhaseCompleted`: «На согласовании» → «Согласовано» **или**
«Выполнение»), а не только на «Согласовано»: если в маршруте есть исполнитель, заявка сразу становится
«Выполнение». Решение исполнителя повторно отправку не запускает. В зелёном сообщении «Решение сохранено»
появляется строка «Документы отправлены на подписание в ЭДО…»:
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как - **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как
получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан. получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан.
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
@@ -16,7 +16,7 @@ import {
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
} from '../../shared/approval'; } from '../../shared/approval';
import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; import { isApprovalPhaseCompleted } from '../../shared/approval/models/statuses';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { import {
approvalListTitle, approvalListTitle,
@@ -145,20 +145,27 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
// поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно // поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно
// кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО // кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО
// отклонит как дубль — признака «уже отправлено» в заявке нет. // отклонит как дубль — признака «уже отправлено» в заявке нет.
const sendToSigning = (): Promise<void> => { // Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче —
// ничего (ошибку и кнопку повтора показывает блок ниже).
const sendToSigning = (): Promise<string | undefined> => {
setSigningError(''); setSigningError('');
setSigningResult(undefined); setSigningResult(undefined);
setSigningBusy(true); setSigningBusy(true);
return sendVacationForSigning(context, { data, employeeName, itemId, code }) return sendVacationForSigning(context, { data, employeeName, itemId, code })
.then((result) => { .then((result): string | undefined => {
setSigningResult(result); setSigningResult(result);
return `Документы отправлены на подписание в ЭДО (${result.sent.join(', ')}): ссылка для подписания ушла на ${data.Employee}.`;
}) })
.catch((e) => { .catch((e): string | undefined => {
setSigningError( setSigningError(
`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`
); );
return undefined;
}) })
.then(() => setSigningBusy(false)); .then((note) => {
setSigningBusy(false);
return note;
});
}; };
return ( return (
@@ -198,11 +205,6 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
<div className="rp-no-print"> <div className="rp-no-print">
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>} {signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
{!signingBusy && signingResult && (
<Alert variant="ok">
{`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`}
</Alert>
)}
{!signingBusy && signingResult && signingResult.skipped.length > 0 && ( {!signingBusy && signingResult && signingResult.skipped.length > 0 && (
<Alert variant="err"> <Alert variant="err">
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`} {`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
@@ -224,10 +226,14 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
notify(); notify();
// «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет). // Это решение завершило согласование: подписывать пора, а не когда заявка «Согласовано».
// Если в маршруте есть исполнитель, последний согласующий переводит заявку сразу в
// «Выполнение», и проверка только на «Согласовано» пропускала такие заявки. Предыдущий
// статус (`status` — до этого решения) отсекает решения исполнителя.
// Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение // Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись. // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись; а его
return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined; // результат панель допишет к зелёному сообщению.
return isApprovalPhaseCompleted(status, newStatus) ? sendToSigning() : undefined;
}} }}
onDelegated={() => { onDelegated={() => {
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
@@ -17,7 +17,7 @@ export interface IApproverOrgInfo {
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations * email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы. * отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
*/ */
export function resolveApproverOrgInfo(context: WebPartContext) { export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise<IApproverOrgInfo> {
return async (email: string): Promise<IApproverOrgInfo> => { return async (email: string): Promise<IApproverOrgInfo> => {
const records = await getEmployeeOrganizations(context, email); const records = await getEmployeeOrganizations(context, email);
const first = records[0]; const first = records[0];
@@ -6,6 +6,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
REQUEST_STATUS_CHOICES, REQUEST_STATUS_CHOICES,
initialSubmitStatus, initialSubmitStatus,
isApprovalPhaseCompleted,
isEditableStatus, isEditableStatus,
isSubmittedStatus, isSubmittedStatus,
} from './statuses'; } from './statuses';
@@ -54,6 +55,41 @@ describe('initialSubmitStatus', () => {
// возвращает 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', () => { describe('isSubmittedStatus', () => {
it('любой результат initialSubmitStatus считается отправкой', () => { it('любой результат initialSubmitStatus считается отправкой', () => {
[initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) => [initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) =>
@@ -61,6 +61,21 @@ export function initialSubmitStatus(approversCount: number, executorsCount: numb
return REQUEST_STATUS.approved; 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;
}
/** /**
* Статус заявки означает «отправлена» (в отличие от черновика)? * Статус заявки означает «отправлена» (в отличие от черновика)?
* *
@@ -23,8 +23,12 @@ interface IDecisionPanelProps {
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту * Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
* уже применено на сервере; сообщить о проблеме побочного действия должен сам * уже применено на сервере; сообщить о проблеме побочного действия должен сам
* обработчик (у него для этого есть своя область экрана). * обработчик (у него для этого есть своя область экрана).
*
* Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…»
* (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно
* не удалось — не возвращайте ничего, сообщение останется прежним.
*/ */
onDecided?: (status: string) => void | Promise<void>; onDecided?: (status: string) => void | string | undefined | Promise<void | string | undefined>;
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные /** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
* согласующим). Если бросит — решение не применяется, показывается ошибка. * согласующим). Если бросит — решение не применяется, показывается ошибка.
* decision — какое решение применяется (проверки, нужные только при * decision — какое решение применяется (проверки, нужные только при
@@ -66,6 +70,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
const [comment, setComment] = useState<string>(''); const [comment, setComment] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false); const [submitting, setSubmitting] = useState<boolean>(false);
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined); const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
// Что обработчик onDecided велел дописать к сообщению об успехе.
const [resultNote, setResultNote] = useState<string | undefined>(undefined);
const [delegated, setDelegated] = useState<number>(0); const [delegated, setDelegated] = useState<number>(0);
const [showDelegate, setShowDelegate] = useState<boolean>(false); const [showDelegate, setShowDelegate] = useState<boolean>(false);
// Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе // Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе
@@ -119,13 +125,16 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
// отработал, кнопки остаются заблокированными (submitting), и пользователь не // отработал, кнопки остаются заблокированными (submitting), и пользователь не
// видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не // видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не
// влияет — оно уже применено, поэтому сообщение показываем всё равно. // влияет — оно уже применено, поэтому сообщение показываем всё равно.
let note: string | undefined;
if (onDecided) { if (onDecided) {
try { try {
await onDecided(status); const returned = await onDecided(status);
note = typeof returned === 'string' && returned ? returned : undefined;
} catch (e) { } catch (e) {
console.error('Побочное действие после решения не выполнилось', e); console.error('Побочное действие после решения не выполнилось', e);
} }
} }
setResultNote(note);
setResultStatus(status); setResultStatus(status);
setSubmitting(false); setSubmitting(false);
} catch (e) { } catch (e) {
@@ -205,7 +214,7 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
<div className={styles.panel}> <div className={styles.panel}>
<Alert variant="ok"> <Alert variant="ok">
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: « {isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
{resultStatus}». {resultStatus}».{resultNote ? ` ${resultNote}` : ''}
</Alert> </Alert>
</div> </div>
); );
@@ -78,7 +78,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
onDelete, onDelete,
}) => { }) => {
const { saving, kind } = submit; const { saving, kind } = submit;
const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId); const unfrozenForm = formId !== undefined && UNFROZEN_FORMS.has(formId);
// false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не // false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не
// мигать разблокированными кнопками до подтверждения, что это тестировщик. // мигать разблокированными кнопками до подтверждения, что это тестировщик.
const [isTester, setIsTester] = React.useState<boolean>(false); const [isTester, setIsTester] = React.useState<boolean>(false);