Отпуск: последний согласующий сначала отправляет заявление в ЭДО — не ушло, решение не сохраняется и заявка остаётся на согласовании; повтор не плодит дубли
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`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`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 'Документы отправлены на подписание в ЭДО.';
|
|
||||||
})
|
|
||||||
.catch((e): string | undefined => {
|
|
||||||
setSigningError(
|
|
||||||
`Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}`
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.then((note) => {
|
|
||||||
setSigningBusy(false);
|
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 (
|
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) — отдаём
|
||||||
|
|||||||
Reference in New Issue
Block a user