Отпуск: последний согласующий сначала отправляет заявление в ЭДО — не ушло, решение не сохраняется и заявка остаётся на согласовании; повтор не плодит дубли
Tests / frontend-test (pull_request) Successful in 1m23s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-05 12:34:07 +05:00
parent dcb0b2983d
commit 03f48af48e
7 changed files with 128 additions and 92 deletions
@@ -30,14 +30,18 @@
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`. - **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
## Подписание в ЭДО ## Подписание в ЭДО
После того как все согласующие согласовали, из браузера **последнего согласующего** Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
(`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО. Срабатывает на решение, которое (`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
завершило фазу согласования (`isApprovalPhaseCompleted`: «На согласовании» → «Согласовано» **или** «На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
«Выполнение»), а не только на «Согласовано»: если в маршруте есть исполнитель, заявка сразу становится исполнители не считаются, параллельные согласующие учтены). **Не ушло в ЭДО — решение не сохраняется**:
«Выполнение». Решение исполнителя повторно отправку не запускает. В зелёном сообщении «Решение сохранено» заявка остаётся на согласовании, ошибка видна в панели решения, согласовать можно ещё раз. Иначе заявка
появляется строка «Документы отправлены на подписание в ЭДО…»: ушла бы дальше по маршруту, а подписывать было бы нечего. Повтор безопасен: DOCX собирается одинаково
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как (фиксированная дата записей архива), и уже принятые файлы ЭДО узнаёт по хэшу — ответ `duplicate_document`
получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан. считается успехом. В зелёном сообщении «Решение сохранено» появляется «Документы отправлены на подписание
в ЭДО». Кнопка «Повторить отправку на подпись» осталась для заявок, согласованных до этой схемы:
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
исполнителя — уведомлений о подписях нет.
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба. переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где - **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base'; import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss'; import styles from './VacationView.module.scss';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui'; import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
@@ -16,7 +16,9 @@ import {
notifyActiveStep, notifyActiveStep,
useRequestTypeId useRequestTypeId
} from '../../shared/approval'; } from '../../shared/approval';
import { isApprovalPhaseCompleted } from '../../shared/approval/models/statuses'; import { completesApprovalPhase } from '../../shared/approval/models/statuses';
import type { ApproverDecision } from '../../shared/approval';
import { isAlreadyAccepted } from '../../shared/edo/edoErrors';
import { executionPhaseRows } from '../../shared/approval/useRouteExecutors'; import { executionPhaseRows } from '../../shared/approval/useRouteExecutors';
import { friendlyErrorMessage } from '../../shared/diagnostics'; import { friendlyErrorMessage } from '../../shared/diagnostics';
import { import {
@@ -83,6 +85,8 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу. // reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
const [decisionSeq, setDecisionSeq] = useState<number>(0); const [decisionSeq, setDecisionSeq] = useState<number>(0);
// Заявление ушло в ЭДО перед этим решением (см. sendBeforeFinalApproval) — сказать об этом в «Решение сохранено».
const sentBeforeDecision = useRef<boolean>(false);
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
approval.getJobTitle(l) approval.getJobTitle(l)
@@ -141,38 +145,48 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
}).catch(() => undefined); }).catch(() => undefined);
}; };
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, — // Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
// по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено, // Исполнитель — первый в фазе исполнения маршрута: он инициатор в ЭДО. Повтор безопасен: файл
// поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно // собирается одинаково, и уже принятое ЭДО узнаёт по хэшу (duplicate_document) — это тоже успех.
// кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО const submitToEdo = async (): Promise<void> => {
// отклонит как дубль — признака «уже отправлено» в заявке нет.
// Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче —
// ничего (ошибку и кнопку повтора показывает блок ниже).
const sendToSigning = (): Promise<string | undefined> => {
setSigningError(''); setSigningError('');
setSigningResult(undefined); setSigningResult(undefined);
setSigningBusy(true); setSigningBusy(true);
// Исполнитель — первый в фазе исполнения маршрута; берём свежие строки, а не те, что на экране: try {
// решение только что применено, и экран мог ещё не перечитать маршрут. const rows = await approval.getMatrixForRequest(itemId);
return approval const initiatorEmail = executionPhaseRows(rows).filter((r) => r.email)[0]?.email;
.getMatrixForRequest(itemId) try {
.then((rows) => executionPhaseRows(rows).filter((r) => r.email)[0]?.email) setSigningResult(await sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail }));
.catch((): string | undefined => undefined) } catch (e) {
.then((initiatorEmail) => sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail })) if (!isAlreadyAccepted(e)) throw e;
.then((result): string | undefined => { }
setSigningResult(result); } finally {
return 'Документы отправлены на подписание в ЭДО.'; setSigningBusy(false);
}) }
.catch((e): string | undefined => { };
setSigningError(
`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` // Решение последнего согласующего применяется, только когда заявление уже в ЭДО: иначе заявка
); // ушла бы дальше по маршруту, а подписывать было бы нечего. Не ушло — решение не сохраняется,
return undefined; // заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
}) const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
.then((note) => { sentBeforeDecision.current = false;
setSigningBusy(false); if (decision !== 'approve') return;
return note;
}); if (!completesApprovalPhase(status, await approval.getMatrixForRequest(itemId))) return;
try {
await submitToEdo();
} catch (e) {
throw new Error(`заявление не ушло на подпись в ЭДО, заявка осталась на согласовании — ${friendlyErrorMessage(e)}`);
}
sentBeforeDecision.current = true;
};
// Для заявок, согласованных раньше, когда отправка шла после решения и могла не удаться.
const retrySigning = (): void => {
submitToEdo().catch((e) =>
setSigningError(`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`)
);
}; };
return ( return (
@@ -220,7 +234,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
{!signingBusy && signingError && ( {!signingBusy && signingError && (
<> <>
<Alert variant="err">{signingError}</Alert> <Alert variant="err">{signingError}</Alert>
<Button variant="secondary" onClick={() => sendToSigning().catch(() => undefined)}> <Button variant="secondary" onClick={retrySigning}>
Повторить отправку на подпись Повторить отправку на подпись
</Button> </Button>
</> </>
@@ -229,18 +243,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
service={approverService} service={approverService}
requestId={itemId} requestId={itemId}
search={(q) => approval.searchUsers(q)} search={(q) => approval.searchUsers(q)}
onBeforeDecide={sendBeforeFinalApproval}
onDecided={(newStatus) => { onDecided={(newStatus) => {
setStatus(newStatus); setStatus(newStatus);
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
notify(); notify();
// Это решение завершило согласование: подписывать пора, а не когда заявка «Согласовано». return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined;
// Если в маршруте есть исполнитель, последний согласующий переводит заявку сразу в
// «Выполнение», и проверка только на «Согласовано» пропускала такие заявки. Предыдущий
// статус (`status` — до этого решения) отсекает решения исполнителя.
// Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись; а его
// результат панель допишет к зелёному сообщению.
return isApprovalPhaseCompleted(status, newStatus) ? sendToSigning() : undefined;
}} }}
onDelegated={() => { onDelegated={() => {
setDecisionSeq((n) => n + 1); setDecisionSeq((n) => n + 1);
@@ -129,11 +129,15 @@ const ROOT_RELS_XML =
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса * Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием. * документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
*/ */
const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> { export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> {
const zip = new JSZip(); const zip = new JSZip();
// Без записей каталогов (_rels/, word/): в файлах, собранных Word, их нет, и строгие // Без записей каталогов (_rels/, word/): в файлах, собранных Word, их нет, и строгие
// читатели пакета (в том числе конвертер ЭДО) не должны на них спотыкаться. // читатели пакета (в том числе конвертер ЭДО) не должны на них спотыкаться.
const options = { createFolders: false }; // Дата записей в архиве фиксирована: иначе JSZip ставит «сейчас», и одно и то же заявление при
// повторной отправке давало бы другой файл — ЭДО не узнал бы повтор и завёл бы второй документ.
const options = { createFolders: false, date: FIXED_ZIP_DATE };
zip.file('[Content_Types].xml', CONTENT_TYPES_XML, options); zip.file('[Content_Types].xml', CONTENT_TYPES_XML, options);
zip.file('_rels/.rels', ROOT_RELS_XML, options); zip.file('_rels/.rels', ROOT_RELS_XML, options);
zip.file('word/document.xml', buildDocumentXml(input), options); zip.file('word/document.xml', buildDocumentXml(input), options);
@@ -6,7 +6,7 @@ import {
REQUEST_STATUS, REQUEST_STATUS,
REQUEST_STATUS_CHOICES, REQUEST_STATUS_CHOICES,
initialSubmitStatus, initialSubmitStatus,
isApprovalPhaseCompleted, completesApprovalPhase,
isEditableStatus, isEditableStatus,
isSubmittedStatus, isSubmittedStatus,
} from './statuses'; } from './statuses';
@@ -55,38 +55,29 @@ describe('initialSubmitStatus', () => {
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки // возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
// маршрута получают недопустимый статус, а активации не происходит вовсе — // маршрута получают недопустимый статус, а активации не происходит вовсе —
// именно так заявка без согласующих перестала попадать в очередь исполнения. // именно так заявка без согласующих перестала попадать в очередь исполнения.
describe('isApprovalPhaseCompleted', () => { describe('completesApprovalPhase', () => {
it('последний согласующий без исполнителей: «На согласовании» → «Согласовано»', () => { const row = (status: string, phase: string = APPROVAL_PHASE.approval): { status: string; phase: string } => ({ status, phase });
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.approved)).toBe(true);
it('осталась одна несогласованная строка — это решение завершит согласование', () => {
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.approved), row(REQUEST_STATUS.onApproval)])).toBe(true);
}); });
// Тот самый случай: в маршруте есть исполнитель, и заявка сразу становится «Выполнение». // Исполнитель в маршруте не мешает: его строка фазы «Исполнение».
it('последний согласующий при наличии исполнителя: «На согласовании» → «Выполнение»', () => { it('строки исполнителей не считаются', () => {
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.execution)).toBe(true); expect(
completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди', APPROVAL_PHASE.execution)])
).toBe(true);
}); });
it('согласование завершилось, исполнители уже отработали: → «Закрыта»', () => { it('впереди ещё согласующие (в том числе параллельные) — не завершит', () => {
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.closed)).toBe(true); expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row(REQUEST_STATUS.onApproval)])).toBe(false);
}); expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди')])).toBe(false);
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('решение исполнителя — не завершение согласования', () => { it('заявка уже не на согласовании (исполнение, черновик) — нет', () => {
expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.execution)).toBe(false); expect(completesApprovalPhase(REQUEST_STATUS.execution, [row(REQUEST_STATUS.onApproval)])).toBe(false);
expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.closed)).toBe(false); expect(completesApprovalPhase(undefined, [row(REQUEST_STATUS.onApproval)])).toBe(false);
});
it('предыдущий статус неизвестен — не гадаем', () => {
expect(isApprovalPhaseCompleted(undefined, REQUEST_STATUS.approved)).toBe(false);
expect(isApprovalPhaseCompleted('', REQUEST_STATUS.execution)).toBe(false);
}); });
}); });
@@ -61,21 +61,6 @@ 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;
}
/** /**
* Статус заявки означает «отправлена» (в отличие от черновика)? * Статус заявки означает «отправлена» (в отличие от черновика)?
* *
@@ -167,3 +152,21 @@ export const APPROVAL_PHASE_CHOICES: string[] = [
APPROVAL_PHASE.approval, APPROVAL_PHASE.approval,
APPROVAL_PHASE.execution, APPROVAL_PHASE.execution,
]; ];
/**
* Решение «Согласовать» сейчас завершит фазу СОГЛАСОВАНИЯ: заявка «На согласовании», и среди строк
* согласования (не исполнения) несогласованной осталась одна — та, по которой решает текущий
* согласующий. Проверяется ДО решения: то, что должно случиться по итогам согласования (например,
* отправка заявления в ЭДО), можно сделать заранее и при неудаче не применять решение вовсе.
*
* Параллельные согласующие (одинаковый Order) учтены: пока кто-то из них не согласовал, строк
* остаётся больше одной. Исполнители не считаются — их строки фазы «Исполнение».
*/
export function completesApprovalPhase(
currentStatus: string | undefined,
rows: ReadonlyArray<{ status: string; phase?: string }>
): boolean {
if (currentStatus !== REQUEST_STATUS.onApproval) return false;
const pending = rows.filter((r) => r.phase !== APPROVAL_PHASE.execution && r.status !== REQUEST_STATUS.approved);
return pending.length === 1;
}
@@ -1,7 +1,7 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base'; import { WebPartContext } from '@microsoft/sp-webpart-base';
import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config'; import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config';
import { describeEdoError } from './edoErrors'; import { EdoRequestError } from './edoErrors';
import { IFioParts } from './fio'; import { IFioParts } from './fio';
/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */ /** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */
@@ -87,7 +87,7 @@ export async function submitSigningRequest(
} }
if (!response.ok) { if (!response.ok) {
throw new Error(describeEdoError(response.status, await response.text())); throw new EdoRequestError(response.status, await response.text());
} }
const body = (await response.json()) as IEdoResponseBody; const body = (await response.json()) as IEdoResponseBody;
@@ -1,3 +1,29 @@
/** Ответ ЭДО с ошибкой: текст для человека и машинный код из ProblemDetails (`code`). */
export class EdoRequestError extends Error {
public readonly status: number;
public readonly code: string | undefined;
public constructor(status: number, bodyText: string) {
super(describeEdoError(status, bodyText));
this.name = 'EdoRequestError';
this.status = status;
this.code = edoErrorCode(bodyText);
}
}
/** Машинный код ошибки ЭДО (`code` в ProblemDetails) или undefined. */
export function edoErrorCode(bodyText: string): string | undefined {
try {
const parsed = JSON.parse(bodyText) as { code?: unknown };
return typeof parsed.code === 'string' ? parsed.code : undefined;
} catch {
return undefined;
}
}
/** ЭДО уже принял эти файлы раньше (повтор запроса): для отправителя это успех, а не ошибка. */
export const isAlreadyAccepted = (e: unknown): boolean => e instanceof EdoRequestError && e.code === 'duplicate_document';
/** /**
* Текст ошибки из ответа ЭДО. Бэкенд отвечает ProblemDetails (`title`, `detail`, `errors`, `code`), * Текст ошибки из ответа ЭДО. Бэкенд отвечает ProblemDetails (`title`, `detail`, `errors`, `code`),
* и именно `title`/`detail` написаны по-русски для человека. Тело не JSON (прокси, 502) — отдаём * и именно `title`/`detail` написаны по-русски для человека. Тело не JSON (прокси, 502) — отдаём