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';
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<IServiceRequestViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
// onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта
// загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя.
const notifyCode = useStableCallback(onCodeLoaded);
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
@@ -95,7 +99,7 @@ const ServiceRequestView: React.FC<IServiceRequestViewProps> = ({ 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<IServiceRequestViewProps> = ({ context, itemI
return () => {
alive = false;
};
}, [itemId, listId]);
}, [itemId, listId, notifyCode]);
// Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection).
// Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров,
@@ -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);
});
});
@@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map<string, IDayInfo>): number {
return total;
}
/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (IDayInfo | null)[] {
/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */
export function buildMonthCells(year: number, month: number, byDay: Map<string, IDayInfo>): (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: '' });
@@ -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);
/**
@@ -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); ЭДО сам
@@ -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<IVacationViewProps> = ({ context, itemId, formKey,
// поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно
// кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО
// отклонит как дубль — признака «уже отправлено» в заявке нет.
const sendToSigning = (): Promise<void> => {
// Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче —
// ничего (ошибку и кнопку повтора показывает блок ниже).
const sendToSigning = (): Promise<string | undefined> => {
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<IVacationViewProps> = ({ context, itemId, formKey,
<div className="rp-no-print">
{signingBusy && <Alert variant="ok">Отправляем заявление на подпись в ЭДО…</Alert>}
{!signingBusy && signingResult && (
<Alert variant="ok">
{`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`}
</Alert>
)}
{!signingBusy && signingResult && signingResult.skipped.length > 0 && (
<Alert variant="err">
{`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`}
@@ -224,10 +226,14 @@ const VacationView: React.FC<IVacationViewProps> = ({ 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);
@@ -17,7 +17,7 @@ export interface IApproverOrgInfo {
* email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations
* отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы.
*/
export function resolveApproverOrgInfo(context: WebPartContext) {
export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise<IApproverOrgInfo> {
return async (email: string): Promise<IApproverOrgInfo> => {
const records = await getEmployeeOrganizations(context, email);
const first = records[0];
@@ -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) =>
@@ -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;
}
/**
* Статус заявки означает «отправлена» (в отличие от черновика)?
*
@@ -23,8 +23,12 @@ interface IDecisionPanelProps {
* Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту
* уже применено на сервере; сообщить о проблеме побочного действия должен сам
* обработчик (у него для этого есть своя область экрана).
*
* Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…»
* (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно
* не удалось — не возвращайте ничего, сообщение останется прежним.
*/
onDecided?: (status: string) => void | Promise<void>;
onDecided?: (status: string) => void | string | undefined | Promise<void | string | undefined>;
/** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные
* согласующим). Если бросит — решение не применяется, показывается ошибка.
* decision — какое решение применяется (проверки, нужные только при
@@ -66,6 +70,8 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
const [comment, setComment] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const [resultStatus, setResultStatus] = useState<string | undefined>(undefined);
// Что обработчик onDecided велел дописать к сообщению об успехе.
const [resultNote, setResultNote] = useState<string | undefined>(undefined);
const [delegated, setDelegated] = useState<number>(0);
const [showDelegate, setShowDelegate] = useState<boolean>(false);
// Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе
@@ -119,13 +125,16 @@ export const DecisionPanel: React.FC<IDecisionPanelProps> = ({
// отработал, кнопки остаются заблокированными (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<IDecisionPanelProps> = ({
<div className={styles.panel}>
<Alert variant="ok">
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: «
{resultStatus}».
{resultStatus}».{resultNote ? ` ${resultNote}` : ''}
</Alert>
</div>
);
@@ -78,7 +78,7 @@ export const SubmitActions: React.FC<ISubmitActionsProps> = ({
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<boolean>(false);