diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index f3aec8e..8377be7 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -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. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба. - **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 30b2b6b..2063d50 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -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 = ({ context, itemId, formKey, // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); + // Заявление ушло в ЭДО перед этим решением (см. sendBeforeFinalApproval) — сказать об этом в «Решение сохранено». + const sentBeforeDecision = useRef(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 = ({ context, itemId, formKey, }).catch(() => undefined); }; - // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, — - // по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено, - // поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно - // кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО - // отклонит как дубль — признака «уже отправлено» в заявке нет. - // Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче — - // ничего (ошибку и кнопку повтора показывает блок ниже). - const sendToSigning = (): Promise => { + // Заявление уходит на подпись в ЭДО (по документу на каждую компанию сотрудника, одним запросом). + // Исполнитель — первый в фазе исполнения маршрута: он инициатор в ЭДО. Повтор безопасен: файл + // собирается одинаково, и уже принятое ЭДО узнаёт по хэшу (duplicate_document) — это тоже успех. + const submitToEdo = async (): Promise => { 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) => { - setSigningBusy(false); - return 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); + } + }; + + // Решение последнего согласующего применяется, только когда заявление уже в ЭДО: иначе заявка + // ушла бы дальше по маршруту, а подписывать было бы нечего. Не ушло — решение не сохраняется, + // заявка остаётся на согласовании, и ошибку видно в панели решения; согласовать можно ещё раз. + const sendBeforeFinalApproval = async (decision: ApproverDecision): Promise => { + 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 = ({ context, itemId, formKey, {!signingBusy && signingError && ( <> {signingError} - @@ -229,18 +243,12 @@ const VacationView: React.FC = ({ 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); diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts index c2c452d..4ffe580 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts @@ -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 { 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); diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts index 638231d..0f75de6 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts @@ -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); }); }); diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index a3e38b0..a3b1602 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -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; +} diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index 533588c..f67c4b6 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -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; diff --git a/src/webparts/requestPortal/shared/edo/edoErrors.ts b/src/webparts/requestPortal/shared/edo/edoErrors.ts index 31a8cc4..08eab46 100644 --- a/src/webparts/requestPortal/shared/edo/edoErrors.ts +++ b/src/webparts/requestPortal/shared/edo/edoErrors.ts @@ -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) — отдаём