Feature/vacation edo signing (#23)
Tests / frontend-test (push) Successful in 1m24s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/timur.gorodetskiy/request-portal/pulls/23
This commit was merged in pull request #23.
This commit is contained in:
2026-10-05 08:53:18 +00:00
parent f8ab2a1d71
commit 637e51d7f9
85 changed files with 2152 additions and 171 deletions
@@ -0,0 +1,101 @@
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config';
import { EdoRequestError } from './edoErrors';
import { IFioParts } from './fio';
/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */
const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests';
/** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */
/** Как портал называет себя в ЭДО. */
export const EDO_SOURCE_NAME = 'Портал заявок';
export interface IEdoSigningFile {
name: string;
content: Blob;
/** БИН компании файла. У сотрудника-совместителя файлы одного запроса идут от разных компаний. */
companyBin: string;
}
/** Что отправляем в ЭДО: одного получателя, он же подписант (физлицо), и файлы. */
export interface IEdoSigningRequest {
/** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */
recipient: { iin: string; email: string; name: IFioParts };
/** Кому сообщать о подписи; пусто — никому. */
initiatorEmail?: string;
files: IEdoSigningFile[];
}
/** Ответ ЭДО: то, что нужно показать пользователю. */
export interface IEdoSigningResult {
/** Кабинет получателя без ключа: войти по ЭЦП или через Microsoft. */
cabinetUrl: string;
documents: Array<{
id: string;
fileName: string;
}>;
}
/** Ответ ЭДО в snake_case — только поля, которые читаем. */
interface IEdoResponseBody {
cabinet_url?: string;
documents?: Array<{ id?: string; file_name?: string }>;
}
/**
* Отправляет документы на подпись в ЭДО одним запросом: либо принимаются все, либо ни один.
*
* Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО
* запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при
* первом обращении. Адрес ЭДО — свойство веб-части edoApiBaseUrl.
*/
export async function submitSigningRequest(
context: WebPartContext,
request: IEdoSigningRequest
): Promise<IEdoSigningResult> {
const baseUrl = getEdoApiBaseUrl();
const client = await context.aadHttpClientFactory.getClient(EDO_API_CLIENT_ID);
// Content-Type не задаём: браузер сам допишет boundary для multipart.
const form = new FormData();
form.append('iin', request.recipient.iin);
form.append('email', request.recipient.email);
form.append('last_name', request.recipient.name.lastName);
form.append('first_name', request.recipient.name.firstName);
if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName);
if (request.initiatorEmail) form.append('initiator_email', request.initiatorEmail);
// Так ЭДО подпишет документ в колонке «Кто отправил», а не техническим client_id.
form.append('service_initiator_name', EDO_SOURCE_NAME);
// БИН файлов — одним полем JSON-массивом строк, по порядку файлов: общий company_bin не нужен,
// подписант один (физлицо). Повторяющиеся поля ЭДО не принимает (400 invalid_file_company_bins_format).
for (const file of request.files) {
form.append('files', file.content, file.name);
}
form.append('file_company_bins', JSON.stringify(request.files.map((file) => file.companyBin)));
let response: HttpClientResponse;
try {
response = await client.post(`${baseUrl}${SIGNING_REQUESTS_PATH}`, AadHttpClient.configurations.v1, { body: form });
} catch (e) {
// Сеть/сертификат: у браузера для таких случаев одно сообщение «Failed to fetch»,
// а причина (недоверенный сертификат, ЭДО выключен, адрес не тот) видна только в консоли.
console.error('ЭДО недоступен', e);
throw new Error(
`ЭДО (${baseUrl}) недоступен. Проверьте, что сервер запущен и браузер доверяет его сертификату`
);
}
if (!response.ok) {
throw new EdoRequestError(response.status, await response.text());
}
const body = (await response.json()) as IEdoResponseBody;
return {
cabinetUrl: body.cabinet_url || '',
documents: (body.documents || []).map((d) => ({
id: d.id || '',
fileName: d.file_name || ''
}))
};
}
@@ -0,0 +1,152 @@
import { ICompany } from '../dictionary/models/ICompany';
import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization';
import { describeEdoError } from './edoErrors';
import { splitFio } from './fio';
import { resolveSigningTargets } from './signingTarget';
const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96';
function record(overrides: Partial<IEmployeeOrganization> = {}): IEmployeeOrganization {
return {
id: 'e1',
fio: 'Иванов Иван Иванович',
email: 'ivanov@tnsu.kz',
iin: '950515123452',
company: 'ТОО TANSU Construction',
companyId: TANSU_ID,
section: '',
departmentId: '',
position: 'Инженер',
mobile: '',
...overrides
};
}
const COMPANIES: ICompany[] = [
{ Id: TANSU_ID, Name: 'ТОО TANSU Construction', Bin: '021040005165' },
{ Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО Без БИН', Bin: '' }
];
describe('Ф.И.О. по частям для ЭДО', () => {
it('три слова', () => {
expect(splitFio('Иванов Иван Иванович')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: 'Иванович' });
});
it('без отчества', () => {
expect(splitFio('Иванов Иван')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: '' });
});
it('лишние пробелы и «оглы» не теряются', () => {
expect(splitFio(' Алиев Тимур Расул оглы ')).toEqual({
lastName: 'Алиев',
firstName: 'Тимур',
middleName: 'Расул оглы'
});
});
it('одно слово или пусто — не разобрать', () => {
expect(splitFio('Иванов')).toBeUndefined();
expect(splitFio('')).toBeUndefined();
});
});
const STROY_ID = '0a1b2c3d-0000-4000-8000-000000000003';
const NO_BIN_ID = '0a1b2c3d-0000-4000-8000-000000000002';
describe('кому и по каким компаниям отправлять на подпись', () => {
const companies: ICompany[] = [...COMPANIES, { Id: STROY_ID, Name: 'ТОО TANSU Строй', Bin: '021040005166' }];
it('ИИН из справочника сотрудников, БИН из справочника компаний', () => {
const targets = resolveSigningTargets([record()], companies);
expect(targets.iin).toBe('950515123452');
expect(targets.name.lastName).toBe('Иванов');
expect(targets.companies).toEqual([{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }]);
expect(targets.skipped).toEqual([]);
});
// Заявку заполняют одну, а подписывают по каждой компании, где человек работает.
it('по документу на каждую компанию, со своей должностью', () => {
const targets = resolveSigningTargets(
[record(), record({ company: 'ТОО TANSU Строй', companyId: STROY_ID, position: 'Главный инженер' })],
companies
);
expect(targets.companies).toEqual([
{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' },
{ bin: '021040005166', name: 'ТОО TANSU Строй', position: 'Главный инженер' }
]);
});
it('одна и та же компания дважды не попадает', () => {
const targets = resolveSigningTargets([record(), record({ position: 'Совместитель' })], companies);
expect(targets.companies).toHaveLength(1);
});
it('идентификаторы компаний сравниваются без учёта регистра', () => {
expect(resolveSigningTargets([record({ companyId: TANSU_ID.toUpperCase() })], companies).companies[0].bin).toBe(
'021040005165'
);
});
it('компания без БИН не мешает остальным — уходит в пропущенные с причиной', () => {
const targets = resolveSigningTargets(
[record({ company: 'ТОО Без БИН', companyId: NO_BIN_ID }), record()],
companies
);
expect(targets.companies.map((c) => c.bin)).toEqual(['021040005165']);
expect(targets.skipped).toEqual([{ name: 'ТОО Без БИН', reason: 'в справочнике компаний нет БИН' }]);
});
it('компании нет в справочнике — тоже пропущена', () => {
const targets = resolveSigningTargets(
[record({ company: 'ТОО Неизвестная', companyId: 'ffffffff-0000-4000-8000-000000000000' })],
companies
);
expect(targets.companies).toEqual([]);
expect(targets.skipped[0].name).toBe('ТОО Неизвестная');
});
it('нет записи о сотруднике — понятная причина', () => {
expect(() => resolveSigningTargets([], companies)).toThrow('нет в кадровом справочнике');
});
it('нет ИИН в карточке', () => {
expect(() => resolveSigningTargets([record({ iin: ' ' })], companies)).toThrow('не указан ИИН');
});
it('ФИО из одного слова не годится', () => {
expect(() => resolveSigningTargets([record({ fio: 'Иванов' })], companies)).toThrow('Ф.И.О.');
});
it('ни в одной записи нет компании', () => {
expect(() => resolveSigningTargets([record({ companyId: '' })], companies)).toThrow('не указана компания');
});
});
describe('текст ошибки ЭДО', () => {
it('берёт русский detail из ProblemDetails', () => {
const body = JSON.stringify({ title: 'Bad Request', detail: 'Для компании «X» не загружен сертификат Sigex.' });
expect(describeEdoError(400, body)).toBe('Для компании «X» не загружен сертификат Sigex.');
});
it('title, если detail нет', () => {
expect(describeEdoError(400, JSON.stringify({ title: 'Подписант не найден' }))).toBe('Подписант не найден');
});
it('ошибки проверки собираются в одну строку', () => {
const body = JSON.stringify({ errors: { Iin: ['ИИН неверный'], Email: ['Email неверный', 'Слишком длинный'] } });
expect(describeEdoError(400, body)).toBe('ИИН неверный; Email неверный; Слишком длинный');
});
it('не JSON — по статусу', () => {
expect(describeEdoError(502, '<html>Bad gateway</html>')).toBe('ЭДО ответил 502');
expect(describeEdoError(401, '')).toContain('401');
expect(describeEdoError(403, '')).toContain('403');
});
});
@@ -0,0 +1,63 @@
/** Ответ ЭДО с ошибкой: текст для человека и машинный код из 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) — отдаём
* статус: лучше короткое «ЭДО ответил 502», чем кусок HTML в интерфейсе.
*/
export function describeEdoError(status: number, bodyText: string): string {
let parsed: { title?: unknown; detail?: unknown; errors?: unknown } = {};
try {
parsed = JSON.parse(bodyText) as typeof parsed;
} catch {
// не JSON — разберём по статусу ниже
}
const validation = flattenValidationErrors(parsed.errors);
if (validation) return validation;
const text = [parsed.detail, parsed.title].find((v): v is string => typeof v === 'string' && v.trim().length > 0);
if (text) return text.trim();
if (status === 401) return 'ЭДО не принял токен: вход под этой учётной записью в ЭДО невозможен (401)';
if (status === 403) return 'ЭДО отказал в доступе (403)';
return `ЭДО ответил ${status}`;
}
/** Ошибки проверки формата `{ поле: [сообщения] }` → одна строка. */
function flattenValidationErrors(errors: unknown): string {
if (!errors || typeof errors !== 'object') return '';
const messages: string[] = [];
for (const value of Object.values(errors as Record<string, unknown>)) {
if (Array.isArray(value)) {
for (const message of value) {
if (typeof message === 'string') messages.push(message);
}
}
}
return messages.join('; ');
}
@@ -0,0 +1,19 @@
/** Ф.И.О. по частям — так их принимает ЭДО. */
export interface IFioParts {
lastName: string;
firstName: string;
middleName: string;
}
/**
* «Иванов Иван Иванович» → фамилия, имя, отчество. Справочник отдаёт ФИО одной строкой.
* Два слова — отчества нет; больше трёх (например, «оглы», двойные фамилии) — всё после
* имени уходит в отчество, чтобы ничего не потерять. Одно слово → ничего не определить:
* ЭДО требует и фамилию, и имя.
*/
export function splitFio(fio: string): IFioParts | undefined {
const words = (fio || '').split(/\s+/).filter((w) => w.length > 0);
if (words.length < 2) return undefined;
const [lastName, firstName, ...rest] = words;
return { lastName, firstName, middleName: rest.join(' ') };
}
@@ -0,0 +1,87 @@
import { ICompany } from '../dictionary/models/ICompany';
import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization';
import { IFioParts, splitFio } from './fio';
/** Одна компания, от имени которой подписывается документ. */
export interface ISigningCompany {
/** БИН компании: от её имени документ идёт в Sigex. */
bin: string;
name: string;
/** Должность сотрудника именно в этой компании. */
position: string;
}
/** Компания сотрудника, по которой документ отправить нельзя, и почему. */
export interface ISkippedCompany {
name: string;
reason: string;
}
/** Кто подписывает и по каким компаниям. */
export interface ISigningTargets {
iin: string;
name: IFioParts;
/** По документу на каждую компанию, где у сотрудника есть БИН в справочнике. */
companies: ISigningCompany[];
/** Компании, по которым документ не отправить (нет БИН в справочнике и т. п.). */
skipped: ISkippedCompany[];
}
/**
* Определяет подписанта и компании по справочникам api.tnsu.kz: ИИН и ФИО — из Employee/ByEmail,
* БИН — из Dictionary/Companies по CompanyId.
*
* Сотрудник может работать в нескольких компаниях (на один e-mail — несколько записей), и заявление
* подписывается по каждой из них отдельно: у каждой компании свой документ со своей должностью.
* Одна и та же компания дважды не попадает. Компания без БИН не роняет остальные — она уходит в
* skipped с причиной. Ошибка бросается, только если подписать нельзя вовсе: нет человека, ИИН, ФИО
* или ни одной компании.
*/
export function resolveSigningTargets(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTargets {
if (records.length === 0) {
throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда');
}
// Человек один на все записи: ИИН и ФИО берём из первой, где они есть.
const iin = (records.find((r) => (r.iin || '').trim())?.iin || '').trim();
if (!iin) {
throw new Error('в кадровом справочнике у сотрудника не указан ИИН');
}
const fio = records.find((r) => (r.fio || '').trim())?.fio || '';
const name = splitFio(fio);
if (!name) {
throw new Error(`не удалось разобрать Ф.И.О. «${fio}» на фамилию и имя`);
}
const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c]));
const result: ISigningCompany[] = [];
const skipped: ISkippedCompany[] = [];
const seen = new Set<string>();
for (const record of records) {
if (!record.companyId) continue;
const company = byId.get(record.companyId.toLowerCase());
const bin = (company?.Bin || '').trim();
const companyName = company?.Name || record.company;
if (!bin) {
if (!seen.has(`name:${companyName}`)) {
seen.add(`name:${companyName}`);
skipped.push({ name: companyName, reason: 'в справочнике компаний нет БИН' });
}
continue;
}
if (seen.has(bin)) continue;
seen.add(bin);
result.push({ bin, name: companyName, position: record.position });
}
if (result.length === 0 && skipped.length === 0) {
throw new Error('в карточке сотрудника не указана компания');
}
return { iin, name, companies: result, skipped };
}