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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user