Отпуск: последний согласующий сначала отправляет заявление в ЭДО — не ушло, решение не сохраняется и заявка остаётся на согласовании; повтор не плодит дубли
Tests / frontend-test (pull_request) Successful in 1m23s
Tests / frontend-test (pull_request) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,14 +30,18 @@
|
||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
||||
|
||||
## Подписание в ЭДО
|
||||
После того как все согласующие согласовали, из браузера **последнего согласующего**
|
||||
(`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО. Срабатывает на решение, которое
|
||||
завершило фазу согласования (`isApprovalPhaseCompleted`: «На согласовании» → «Согласовано» **или**
|
||||
«Выполнение»), а не только на «Согласовано»: если в маршруте есть исполнитель, заявка сразу становится
|
||||
«Выполнение». Решение исполнителя повторно отправку не запускает. В зелёном сообщении «Решение сохранено»
|
||||
появляется строка «Документы отправлены на подписание в ЭДО…»:
|
||||
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как
|
||||
получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан.
|
||||
Когда **последний согласующий** нажимает «Согласовать», заявление сначала уходит на подпись в TANSU ЭДО
|
||||
(`VacationView` → `onBeforeDecide`), и только потом применяется решение. Последний — значит заявка
|
||||
«На согласовании» и несогласованной осталась одна строка фазы согласования (`completesApprovalPhase`;
|
||||
исполнители не считаются, параллельные согласующие учтены). **Не ушло в ЭДО — решение не сохраняется**:
|
||||
заявка остаётся на согласовании, ошибка видна в панели решения, согласовать можно ещё раз. Иначе заявка
|
||||
ушла бы дальше по маршруту, а подписывать было бы нечего. Повтор безопасен: DOCX собирается одинаково
|
||||
(фиксированная дата записей архива), и уже принятые файлы ЭДО узнаёт по хэшу — ответ `duplicate_document`
|
||||
считается успехом. В зелёном сообщении «Решение сохранено» появляется «Документы отправлены на подписание
|
||||
в ЭДО». Кнопка «Повторить отправку на подпись» осталась для заявок, согласованных до этой схемы:
|
||||
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»); ему уходит приглашение.
|
||||
**Инициатор** (`initiator_email`, письма о подписях) — исполнитель заявки, первый в фазе исполнения; нет
|
||||
исполнителя — уведомлений о подписях нет.
|
||||
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
|
||||
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
|
||||
- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 styles from './VacationView.module.scss';
|
||||
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
notifyActiveStep,
|
||||
useRequestTypeId
|
||||
} 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 { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||
import {
|
||||
@@ -83,6 +85,8 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
// Матрица маршрута из фактических строк заявки (read-only). decisionSeq в
|
||||
// reloadKey: после решения строки на сервере меняются — перечитываем матрицу.
|
||||
const [decisionSeq, setDecisionSeq] = useState<number>(0);
|
||||
// Заявление ушло в ЭДО перед этим решением (см. sendBeforeFinalApproval) — сказать об этом в «Решение сохранено».
|
||||
const sentBeforeDecision = useRef<boolean>(false);
|
||||
const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]);
|
||||
const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) =>
|
||||
approval.getJobTitle(l)
|
||||
@@ -141,38 +145,48 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, —
|
||||
// по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено,
|
||||
// поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно
|
||||
// кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО
|
||||
// отклонит как дубль — признака «уже отправлено» в заявке нет.
|
||||
// Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче —
|
||||
// ничего (ошибку и кнопку повтора показывает блок ниже).
|
||||
const sendToSigning = (): Promise<string | undefined> => {
|
||||
// Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом).
|
||||
// Исполнитель — первый в фазе исполнения маршрута: он инициатор в ЭДО. Повтор безопасен: файл
|
||||
// собирается одинаково, и уже принятое ЭДО узнаёт по хэшу (duplicate_document) — это тоже успех.
|
||||
const submitToEdo = async (): Promise<void> => {
|
||||
setSigningError('');
|
||||
setSigningResult(undefined);
|
||||
setSigningBusy(true);
|
||||
// Исполнитель — первый в фазе исполнения маршрута; берём свежие строки, а не те, что на экране:
|
||||
// решение только что применено, и экран мог ещё не перечитать маршрут.
|
||||
return approval
|
||||
.getMatrixForRequest(itemId)
|
||||
.then((rows) => executionPhaseRows(rows).filter((r) => r.email)[0]?.email)
|
||||
.catch((): string | undefined => undefined)
|
||||
.then((initiatorEmail) => sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail }))
|
||||
.then((result): string | undefined => {
|
||||
setSigningResult(result);
|
||||
return 'Документы отправлены на подписание в ЭДО.';
|
||||
})
|
||||
.catch((e): string | undefined => {
|
||||
setSigningError(
|
||||
`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`
|
||||
);
|
||||
return undefined;
|
||||
})
|
||||
.then((note) => {
|
||||
try {
|
||||
const rows = await approval.getMatrixForRequest(itemId);
|
||||
const initiatorEmail = executionPhaseRows(rows).filter((r) => r.email)[0]?.email;
|
||||
try {
|
||||
setSigningResult(await sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail }));
|
||||
} catch (e) {
|
||||
if (!isAlreadyAccepted(e)) throw e;
|
||||
}
|
||||
} finally {
|
||||
setSigningBusy(false);
|
||||
return note;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Решение последнего согласующего применяется, только когда заявление уже в ЭДО: иначе заявка
|
||||
// ушла бы дальше по маршруту, а подписывать было бы нечего. Не ушло — решение не сохраняется,
|
||||
// заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз.
|
||||
const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise<void> => {
|
||||
sentBeforeDecision.current = false;
|
||||
if (decision !== 'approve') return;
|
||||
|
||||
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 (
|
||||
@@ -220,7 +234,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
{!signingBusy && signingError && (
|
||||
<>
|
||||
<Alert variant="err">{signingError}</Alert>
|
||||
<Button variant="secondary" onClick={() => sendToSigning().catch(() => undefined)}>
|
||||
<Button variant="secondary" onClick={retrySigning}>
|
||||
Повторить отправку на подпись
|
||||
</Button>
|
||||
</>
|
||||
@@ -229,18 +243,12 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
||||
service={approverService}
|
||||
requestId={itemId}
|
||||
search={(q) => approval.searchUsers(q)}
|
||||
onBeforeDecide={sendBeforeFinalApproval}
|
||||
onDecided={(newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setDecisionSeq((n) => n + 1);
|
||||
notify();
|
||||
// Это решение завершило согласование: подписывать пора, а не когда заявка «Согласовано».
|
||||
// Если в маршруте есть исполнитель, последний согласующий переводит заявку сразу в
|
||||
// «Выполнение», и проверка только на «Согласовано» пропускала такие заявки. Предыдущий
|
||||
// статус (`status` — до этого решения) отсекает решения исполнителя.
|
||||
// Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение
|
||||
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись; а его
|
||||
// результат панель допишет к зелёному сообщению.
|
||||
return isApprovalPhaseCompleted(status, newStatus) ? sendToSigning() : undefined;
|
||||
return sentBeforeDecision.current ? 'Документы отправлены на подписание в ЭДО.' : undefined;
|
||||
}}
|
||||
onDelegated={() => {
|
||||
setDecisionSeq((n) => n + 1);
|
||||
|
||||
@@ -129,11 +129,15 @@ const ROOT_RELS_XML =
|
||||
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
|
||||
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
|
||||
*/
|
||||
const FIXED_ZIP_DATE = new Date(Date.UTC(2020, 0, 1));
|
||||
|
||||
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> {
|
||||
const zip = new JSZip();
|
||||
// Без записей каталогов (_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('_rels/.rels', ROOT_RELS_XML, options);
|
||||
zip.file('word/document.xml', buildDocumentXml(input), options);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
REQUEST_STATUS,
|
||||
REQUEST_STATUS_CHOICES,
|
||||
initialSubmitStatus,
|
||||
isApprovalPhaseCompleted,
|
||||
completesApprovalPhase,
|
||||
isEditableStatus,
|
||||
isSubmittedStatus,
|
||||
} from './statuses';
|
||||
@@ -55,38 +55,29 @@ describe('initialSubmitStatus', () => {
|
||||
// возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки
|
||||
// маршрута получают недопустимый статус, а активации не происходит вовсе —
|
||||
// именно так заявка без согласующих перестала попадать в очередь исполнения.
|
||||
describe('isApprovalPhaseCompleted', () => {
|
||||
it('последний согласующий без исполнителей: «На согласовании» → «Согласовано»', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.approved)).toBe(true);
|
||||
describe('completesApprovalPhase', () => {
|
||||
const row = (status: string, phase: string = APPROVAL_PHASE.approval): { status: string; phase: string } => ({ status, phase });
|
||||
|
||||
it('осталась одна несогласованная строка — это решение завершит согласование', () => {
|
||||
expect(completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.approved), row(REQUEST_STATUS.onApproval)])).toBe(true);
|
||||
});
|
||||
|
||||
// Тот самый случай: в маршруте есть исполнитель, и заявка сразу становится «Выполнение».
|
||||
it('последний согласующий при наличии исполнителя: «На согласовании» → «Выполнение»', () => {
|
||||
expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.execution)).toBe(true);
|
||||
// Исполнитель в маршруте не мешает: его строка фазы «Исполнение».
|
||||
it('строки исполнителей не считаются', () => {
|
||||
expect(
|
||||
completesApprovalPhase(REQUEST_STATUS.onApproval, [row(REQUEST_STATUS.onApproval), row('Ждёт очереди', APPROVAL_PHASE.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(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.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);
|
||||
it('заявка уже не на согласовании (исполнение, черновик) — нет', () => {
|
||||
expect(completesApprovalPhase(REQUEST_STATUS.execution, [row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||
expect(completesApprovalPhase(undefined, [row(REQUEST_STATUS.onApproval)])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -61,21 +61,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Статус заявки означает «отправлена» (в отличие от черновика)?
|
||||
*
|
||||
@@ -167,3 +152,21 @@ export const APPROVAL_PHASE_CHOICES: string[] = [
|
||||
APPROVAL_PHASE.approval,
|
||||
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 { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config';
|
||||
import { describeEdoError } from './edoErrors';
|
||||
import { EdoRequestError } from './edoErrors';
|
||||
import { IFioParts } from './fio';
|
||||
|
||||
/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */
|
||||
@@ -87,7 +87,7 @@ export async function submitSigningRequest(
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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`),
|
||||
* и именно `title`/`detail` написаны по-русски для человека. Тело не JSON (прокси, 502) — отдаём
|
||||
|
||||
Reference in New Issue
Block a user