diff --git a/package-lock.json b/package-lock.json index f67ed01..7e37810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", "@tnsu/ui-kit": "^0.12.0", + "jszip": "~3.8.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/package.json b/package.json index f6a56db..27d97b3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", "@tnsu/ui-kit": "^0.12.0", + "jszip": "~3.8.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json index 4c47d65..9c5e9f3 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json +++ b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json @@ -16,6 +16,6 @@ "title": { "default": "request-portal" }, "description": { "default": "request-portal description" }, "officeFabricIconFontName": "Page", - "properties": { "notificationsEnabled": true } + "properties": { "notificationsEnabled": true, "edoApiBaseUrl": "https://10.0.1.225:8443" } }] } diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 7de97bf..32e26c2 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -19,7 +19,10 @@ import { setImpersonatedIdentity } from './shared/impersonation'; import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, + EDO_API_BASE_URL_LOCAL, + EDO_API_BASE_URL_STAGE, setApprovalApiBaseUrl, + setEdoApiBaseUrl, setNotificationSettings } from './shared/config'; import { initListPartitions } from './shared/approval'; @@ -38,6 +41,11 @@ export interface IRequestPortalWebPartProps { debugUserDisplayName: string; /** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */ approvalApiBaseUrl: string; + /** + * С каким ЭДО работаем при отправке документов на подписание — локальным или + * стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж. + */ + edoApiBaseUrl: string; /** * Уведомления согласующим (поток Power Automate). По умолчанию включены: * у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл». @@ -58,6 +66,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart = ({ context, itemId, formKey, const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); + // Ф.И.О. из заявки (Employee/Title) — запасной вариант для документа на подпись. + const [employeeName, setEmployeeName] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // выбор в тулбаре через PrintSplitButton, по умолчанию первая. const [printOrgIndex, setPrintOrgIndex] = useState(0); + // Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning). + const [signingInfo, setSigningInfo] = useState(''); + const [signingError, setSigningError] = useState(''); + const [signingBusy, setSigningBusy] = useState(false); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // vacationApprovalService можно звать не опасаясь исключения. @@ -84,6 +92,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); + setEmployeeName(loaded.employeeName); notifyCode(loaded.code); setError(null); setLoading(false); @@ -125,6 +134,27 @@ const VacationView: React.FC = ({ context, itemId, formKey, }).catch(() => undefined); }; + // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск. + // Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным + // сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта: + // признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем. + const sendToSigning = (): Promise => { + setSigningError(''); + setSigningInfo('Отправляем заявление на подпись в ЭДО…'); + setSigningBusy(true); + return sendVacationForSigning(context, { data, employeeName, itemId, code }) + .then(() => { + setSigningInfo(`Заявление отправлено на подпись: письмо со ссылкой ушло на ${data.Employee}.`); + }) + .catch((e) => { + setSigningInfo(''); + setSigningError( + `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` + ); + }) + .then(() => setSigningBusy(false)); + }; + return (
@@ -160,6 +190,15 @@ const VacationView: React.FC = ({ context, itemId, formKey,
+ {signingInfo && {signingInfo}} + {signingError && ( + <> + {signingError} + + + )} = ({ context, itemId, formKey, setStatus(newStatus); setDecisionSeq((n) => n + 1); notify(); + // «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет). + // Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение + // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись. + return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined; }} onDelegated={() => { setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/Vacation/dates.ts b/src/webparts/requestPortal/forms/Vacation/dates.ts new file mode 100644 index 0000000..b604a17 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/dates.ts @@ -0,0 +1,12 @@ +/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */ +export function computeEndDate(startIso: string, days: number): string { + if (!startIso || !Number.isFinite(days) || days <= 0) return ''; + const endMs = new Date(startIso).getTime() + (days - 1) * 86400000; + return new Date(endMs).toISOString().slice(0, 10); +} + +/** ISO `YYYY-MM-DD` → `дд.мм.гггг`. Не похоже на дату → строка как есть (чтобы документ не падал из-за мусора). */ +export function formatRuDate(iso: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + return match ? `${match[3]}.${match[2]}.${match[1]}` : iso; +} diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts new file mode 100644 index 0000000..f3b9ea2 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -0,0 +1,82 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary'; +import { IEdoSigningResult, submitSigningRequest } from '../../shared/edo/edo.service'; +import { resolveSigningTarget } from '../../shared/edo/signingTarget'; +import { computeEndDate, formatRuDate } from './dates'; +import { IFormData } from './model'; +import { createVacationDocx, vacationFileName } from './vacationDocument'; + +/** Что нужно знать о согласованной заявке, чтобы отправить её на подпись. */ +export interface IVacationToSign { + /** Данные заявки (прочитаны из списка). */ + data: IFormData; + /** Ф.И.О. сотрудника из заявки (`Employee/Title`) — запасной вариант, если справочник пуст. */ + employeeName: string; + /** Уникальный код заявки (`RequestUniqueCode`); пусто — берётся id. */ + code: string; + itemId: number; +} + +/** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */ +function todayRu(): string { + const now = new Date(); + const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`); + return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()}`; +} + +/** Ф.И.О. замещающего по его e-mail; не нашли — сам адрес, чтобы заявление всё равно собралось. */ +async function substituteName(context: WebPartContext, email: string): Promise { + if (!email) return ''; + try { + const records = await getEmployeeOrganizations(context, email); + return records[0]?.fio || email; + } catch { + // Имя замещающего — украшение документа: из-за него отправку не срываем. + return email; + } +} + +/** + * Отправляет согласованное заявление на отпуск на подпись в ЭДО. + * + * Подписывает сотрудник, указанный в заявке (он же «инициатор»): ему уходит приглашение, а на + * его же почту ЭДО сообщит, что документ подписан. ИИН и БИН компании портал берёт из + * справочников api.tnsu.kz, в самой заявке их нет. + */ +export async function sendVacationForSigning( + context: WebPartContext, + vacation: IVacationToSign +): Promise { + const { data } = vacation; + if (!data.Employee) { + throw new Error('в заявке не указан сотрудник'); + } + + const [records, companies] = await Promise.all([getEmployeeOrganizations(context, data.Employee), getCompanies(context)]); + const target = resolveSigningTarget(records, companies); + + const days = Number(data.DaysCount); + const documentDate = todayRu(); + const employeeName = + [target.name.lastName, target.name.firstName, target.name.middleName].filter((p) => p).join(' ') || + vacation.employeeName; + + const file = await createVacationDocx({ + companyName: target.companyName, + position: data.Position, + employeeName, + startDate: formatRuDate(data.StartDate), + endDate: formatRuDate(computeEndDate(data.StartDate, days)), + daysCount: days, + substituteName: await substituteName(context, data.Substitute), + requestCode: vacation.code || String(vacation.itemId), + documentDate + }); + + return submitSigningRequest(context, { + recipient: { iin: target.iin, email: data.Employee, name: target.name }, + companyBin: target.companyBin, + initiatorEmail: data.Employee, + file: { name: vacationFileName(employeeName, documentDate), content: file } + }); +} diff --git a/src/webparts/requestPortal/forms/Vacation/submit.ts b/src/webparts/requestPortal/forms/Vacation/submit.ts index 947260e..e5bbd27 100644 --- a/src/webparts/requestPortal/forms/Vacation/submit.ts +++ b/src/webparts/requestPortal/forms/Vacation/submit.ts @@ -1,6 +1,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ensureEmployee } from '../../shared/employee'; +import { computeEndDate } from './dates'; import { FORM_ID, IFormData } from './model'; /** @@ -16,13 +17,6 @@ function pad2(n: number): string { return n < 10 ? `0${n}` : `${n}`; } -/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */ -function computeEndDate(startIso: string, days: number): string { - if (!startIso || !Number.isFinite(days) || days <= 0) return ''; - const endMs = new Date(startIso).getTime() + (days - 1) * 86400000; - return new Date(endMs).toISOString().slice(0, 10); -} - /** Осмысленный заголовок элемента (системная колонка Title). */ function buildTitle(employeeName: string): string { const now = new Date(); diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts new file mode 100644 index 0000000..7bd35c0 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts @@ -0,0 +1,96 @@ +import JSZip from 'jszip'; +import { computeEndDate, formatRuDate } from './dates'; +import { + IVacationDocumentInput, + buildDocumentXml, + createVacationDocx, + escapeXml, + vacationFileName +} from './vacationDocument'; + +const INPUT: IVacationDocumentInput = { + companyName: 'ТОО TANSU Construction', + position: 'Инженер', + employeeName: 'Иванов Иван Иванович', + startDate: '01.12.2026', + endDate: '14.12.2026', + daysCount: 14, + substituteName: 'Петров Пётр Петрович', + requestCode: 'HR-0042', + documentDate: '30.09.2026' +}; + +/** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */ +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +describe('даты отпуска', () => { + it('конец = начало + (дни − 1)', () => { + expect(computeEndDate('2026-12-01', 14)).toBe('2026-12-14'); + expect(computeEndDate('2026-12-01', 1)).toBe('2026-12-01'); + }); + + it('без начала или дней — пусто', () => { + expect(computeEndDate('', 5)).toBe(''); + expect(computeEndDate('2026-12-01', 0)).toBe(''); + }); + + it('формат дд.мм.гггг, мусор остаётся как есть', () => { + expect(formatRuDate('2026-12-01')).toBe('01.12.2026'); + expect(formatRuDate('завтра')).toBe('завтра'); + }); +}); + +describe('заявление на отпуск', () => { + it('в тексте есть все данные заявки', () => { + const xml = buildDocumentXml(INPUT); + + for (const part of [ + 'ТОО TANSU Construction', + 'Инженер', + 'Иванов Иван Иванович', + 'ЗАЯВЛЕНИЕ', + '14 календарных дней с 01.12.2026 по 14.12.2026', + 'Петров Пётр Петрович', + 'HR-0042', + '30.09.2026' + ]) { + expect(xml).toContain(part); + } + }); + + it('без замещающего абзаца о замещении нет', () => { + expect(buildDocumentXml({ ...INPUT, substituteName: '' })).not.toContain('исполнение обязанностей'); + }); + + it('специальные символы в данных экранируются, разметка не ломается', () => { + const xml = buildDocumentXml({ ...INPUT, companyName: 'ТОО "Рога & Копыта" <филиал>' }); + + expect(xml).toContain('ТОО "Рога & Копыта" <филиал>'); + expect(xml).not.toContain('Рога & Копыта'); + }); + + it('экранирование', () => { + expect(escapeXml(`&'`)).toBe('<a href="x">&'</a>'); + }); + + it('имя файла без недопустимых символов', () => { + expect(vacationFileName('Иванов И.И.', '30.09.2026')).toBe('Заявление на отпуск — Иванов И.И. — 30.09.2026.docx'); + expect(vacationFileName('A/B:C', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/); + }); + + it('DOCX — это zip с обязательными частями пакета', async () => { + const blob = await createVacationDocx(INPUT); + const zip = await JSZip.loadAsync(await readBlob(blob)); + + expect(Object.keys(zip.files).sort()).toEqual(['[Content_Types].xml', '_rels/.rels', 'word/document.xml']); + const document = await zip.file('word/document.xml')!.async('string'); + expect(document).toContain('ЗАЯВЛЕНИЕ'); + }); +}); diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts new file mode 100644 index 0000000..ac64c5c --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts @@ -0,0 +1,149 @@ +import JSZip from 'jszip'; + +/** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */ +export interface IVacationDocumentInput { + /** Организация, на которую пишется заявление («ТОО TANSU Construction»). */ + companyName: string; + /** Должность заявителя; пусто — строка «должность» не выводится. */ + position: string; + /** Ф.И.О. заявителя. */ + employeeName: string; + /** Начало отпуска, ISO `YYYY-MM-DD`. */ + startDate: string; + /** Конец отпуска, ISO `YYYY-MM-DD`. */ + endDate: string; + daysCount: number; + /** Ф.И.О. замещающего; пусто — абзац о замещении не выводится. */ + substituteName: string; + /** Номер заявки в портале. */ + requestCode: string; + /** Дата составления заявления, `дд.мм.гггг`. */ + documentDate: string; +} + +export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +/** Экранирование для XML: имена и должности приходят из справочников и могут содержать `&`, `<`, кавычки. */ +export function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +type Align = 'left' | 'right' | 'center' | 'both'; + +interface IParagraph { + text: string; + align?: Align; + bold?: boolean; + /** Отступ после абзаца, twips (20 twips = 1 пт). */ + after?: number; + /** Отступ первой строки, twips. */ + firstLine?: number; +} + +// Times New Roman 14 пт — привычный вид официального заявления. Размер в «половинках» пункта. +const FONT = 'Times New Roman'; +const FONT_SIZE_HALF_POINTS = 28; + +function paragraph({ text, align = 'left', bold = false, after = 0, firstLine = 0 }: IParagraph): string { + const indent = firstLine > 0 ? `` : ''; + const run = + `` + + `${bold ? '' : ''}`; + return ( + `${indent}` + + `${run}${escapeXml(text)}` + ); +} + +/** Абзацы заявления. Формулировка — рабочий вариант, финальный текст утверждает кадровая служба. */ +export function buildVacationParagraphs(input: IVacationDocumentInput): IParagraph[] { + const paragraphs: IParagraph[] = [ + // Шапка «кому/от кого»: должность в именительном падеже, поэтому без предлога «от». + { text: input.companyName, align: 'right' }, + ...(input.position ? [{ text: input.position, align: 'right' as const }] : []), + { text: input.employeeName, align: 'right', after: 480 }, + { text: 'ЗАЯВЛЕНИЕ', align: 'center', bold: true, after: 360 }, + { + text: + `Прошу предоставить мне ежегодный оплачиваемый трудовой отпуск продолжительностью ${input.daysCount} ` + + `календарных дней с ${input.startDate} по ${input.endDate} включительно.`, + align: 'both', + firstLine: 709, + after: 240 + } + ]; + + if (input.substituteName) { + paragraphs.push({ + text: `На период моего отсутствия исполнение обязанностей прошу возложить на ${input.substituteName}.`, + align: 'both', + firstLine: 709, + after: 240 + }); + } + + paragraphs.push( + { text: `Дата: ${input.documentDate}`, after: 120 }, + { text: `Заявка в портале №${input.requestCode}`, after: 360 }, + { text: `${input.employeeName} ____________` } + ); + + return paragraphs; +} + +/** Тело `word/document.xml`. Отдельно от упаковки в zip — чтобы проверять разметку без архива. */ +export function buildDocumentXml(input: IVacationDocumentInput): string { + const body = buildVacationParagraphs(input).map(paragraph).join(''); + // A4, поля 2 см сверху/снизу/слева и 1,5 см справа — как у канцелярских бланков. + const section = + '' + + ''; + return ( + '' + + '' + + `${body}${section}` + ); +} + +const CONTENT_TYPES_XML = + '' + + '' + + '' + + '' + + '' + + ''; + +const ROOT_RELS_XML = + '' + + '' + + ''; + +/** + * Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса + * документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием. + */ +export async function createVacationDocx(input: IVacationDocumentInput): Promise { + const zip = new JSZip(); + // Без записей каталогов (_rels/, word/): в файлах, собранных Word, их нет, и строгие + // читатели пакета (в том числе конвертер ЭДО) не должны на них спотыкаться. + const options = { createFolders: false }; + 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); + return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' }); +} + +/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете. */ +export function vacationFileName(employeeName: string, documentDate: string): string { + // Недопустимые в имени файла символы заменяем, а не удаляем: ФИО остаётся читаемым. + const safeName = employeeName.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); + return `Заявление на отпуск — ${safeName} — ${documentDate}.docx`; +} diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 09d48e5..5bab3d7 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -45,6 +45,33 @@ export function getApprovalApiBaseUrl(): string { return _approvalApiBaseUrl; } +/** + * ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и + * у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения + * (Auth:Entra:Audience). Если ЭДО переедет на своё приложение — поменять только эту константу + * и объявить доступ в package-solution.json (webApiPermissionRequests). + */ +export const EDO_API_CLIENT_ID = APPROVAL_API_CLIENT_ID; + +/** Локальный бэкенд ЭДО — порт из launchSettings.json (https). */ +export const EDO_API_BASE_URL_LOCAL = 'https://localhost:7016'; +/** Стейджинг ЭДО. Сертификат самоподписанный: браузер должен ему доверять, иначе запрос не уйдёт. */ +export const EDO_API_BASE_URL_STAGE = 'https://10.0.1.225:8443'; + +// Настраивается свойством веб-части edoApiBaseUrl (селект в панели свойств) — тот же +// модульный синглтон, что и approvalApiBaseUrl выше. +let _edoApiBaseUrl = EDO_API_BASE_URL_STAGE; + +export function setEdoApiBaseUrl(url: string): void { + // Хвостовые слэши отбрасываем: адрес склеивается с путём «/api/...», а в панели + // свойств его могли вписать с «/» на конце. + _edoApiBaseUrl = (url || EDO_API_BASE_URL_STAGE).trim().replace(/\/+$/, ''); +} + +export function getEdoApiBaseUrl(): string { + return _edoApiBaseUrl; +} + // Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts). // Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот // же модульный синглтон, что и у approvalApiBaseUrl выше. diff --git a/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts b/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts index a15fe4e..df481a5 100644 --- a/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts @@ -23,6 +23,7 @@ async function fetchEmployeeOrganizations(context: WebPartContext, email: string id: (r.Id as string) || '', fio: (r.Fio as string) || '', email: (r.Email as string) || '', + iin: (r.Iin as string) || '', company: (r.Company as string) || '', companyId: (r.CompanyId as string) || '', section: (r.Section as string) || '', diff --git a/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts b/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts index 9ed45c5..28ebfbd 100644 --- a/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts +++ b/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts @@ -1,8 +1,10 @@ -// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name } +// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name, Bin } // (PascalCase, без обёртки — как /Dictionary/Departments). export interface ICompany { /** GUID компании — стабильный идентификатор. */ Id: string; /** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */ Name: string; + /** БИН компании, 12 цифр. Нужен для ЭДО (от имени какой компании подписывается документ). */ + Bin?: string; } diff --git a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts index d31d5ff..91f9f3b 100644 --- a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts +++ b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts @@ -8,6 +8,8 @@ export interface IEmployeeOrganization { id: string; fio: string; email: string; + /** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */ + iin: string; company: string; companyId: string; section: string; diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts new file mode 100644 index 0000000..3020dde --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -0,0 +1,80 @@ +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 { IFioParts } from './fio'; + +/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */ +const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests'; + +/** Что отправляем в ЭДО: одного получателя, он же подписант, и один файл. */ +export interface IEdoSigningRequest { + /** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */ + recipient: { iin: string; email: string; name: IFioParts }; + /** БИН компании, от имени которой документ идёт на подпись (нужен сертификат Sigex этой компании в ЭДО). */ + companyBin: string; + /** Кому сообщать о подписи. */ + initiatorEmail: string; + file: { name: string; content: Blob }; +} + +/** Ответ ЭДО: то, что нужно показать пользователю. */ +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 { + 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); + form.append('company_bin', request.companyBin); + form.append('initiator_email', request.initiatorEmail); + form.append('files', request.file.content, request.file.name); + + 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 Error(describeEdoError(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 || '' })) + }; +} diff --git a/src/webparts/requestPortal/shared/edo/edo.test.ts b/src/webparts/requestPortal/shared/edo/edo.test.ts new file mode 100644 index 0000000..09c6d3e --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/edo.test.ts @@ -0,0 +1,125 @@ +import { ICompany } from '../dictionary/models/ICompany'; +import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; +import { describeEdoError } from './edoErrors'; +import { splitFio } from './fio'; +import { resolveSigningTarget } from './signingTarget'; + +const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96'; + +function record(overrides: Partial = {}): 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(); + }); +}); + +describe('кому и от какой компании отправлять на подпись', () => { + it('берёт ИИН из справочника сотрудников и БИН из справочника компаний', () => { + const target = resolveSigningTarget([record()], COMPANIES); + + expect(target.iin).toBe('950515123452'); + expect(target.companyBin).toBe('021040005165'); + expect(target.companyName).toBe('ТОО TANSU Construction'); + expect(target.name.lastName).toBe('Иванов'); + }); + + it('идентификаторы компаний сравниваются без учёта регистра', () => { + const target = resolveSigningTarget([record({ companyId: TANSU_ID.toUpperCase() })], COMPANIES); + + expect(target.companyBin).toBe('021040005165'); + }); + + it('при нескольких организациях берёт первую, где у компании есть БИН', () => { + const records = [ + record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' }), + record() + ]; + + expect(resolveSigningTarget(records, COMPANIES).companyBin).toBe('021040005165'); + }); + + it('нет записи о сотруднике — понятная причина', () => { + expect(() => resolveSigningTarget([], COMPANIES)).toThrow('нет в кадровом справочнике'); + }); + + it('у компании нет БИН — называет компанию', () => { + const records = [record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' })]; + + expect(() => resolveSigningTarget(records, COMPANIES)).toThrow('нет БИН для «ТОО Без БИН»'); + }); + + it('компании нет в справочнике', () => { + expect(() => resolveSigningTarget([record({ companyId: 'ffffffff-0000-4000-8000-000000000000' })], COMPANIES)).toThrow( + 'нет БИН' + ); + }); + + it('нет ИИН в карточке', () => { + expect(() => resolveSigningTarget([record({ iin: ' ' })], COMPANIES)).toThrow('не указан ИИН'); + }); + + it('ФИО из одного слова не годится', () => { + expect(() => resolveSigningTarget([record({ fio: 'Иванов' })], 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, 'Bad gateway')).toBe('ЭДО ответил 502'); + expect(describeEdoError(401, '')).toContain('401'); + expect(describeEdoError(403, '')).toContain('403'); + }); +}); diff --git a/src/webparts/requestPortal/shared/edo/edoErrors.ts b/src/webparts/requestPortal/shared/edo/edoErrors.ts new file mode 100644 index 0000000..31a8cc4 --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/edoErrors.ts @@ -0,0 +1,37 @@ +/** + * Текст ошибки из ответа ЭДО. Бэкенд отвечает 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)) { + if (Array.isArray(value)) { + for (const message of value) { + if (typeof message === 'string') messages.push(message); + } + } + } + return messages.join('; '); +} diff --git a/src/webparts/requestPortal/shared/edo/fio.ts b/src/webparts/requestPortal/shared/edo/fio.ts new file mode 100644 index 0000000..22bc81c --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/fio.ts @@ -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(' ') }; +} diff --git a/src/webparts/requestPortal/shared/edo/signingTarget.ts b/src/webparts/requestPortal/shared/edo/signingTarget.ts new file mode 100644 index 0000000..a846b52 --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/signingTarget.ts @@ -0,0 +1,53 @@ +import { ICompany } from '../dictionary/models/ICompany'; +import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; +import { IFioParts, splitFio } from './fio'; + +/** Кому и от имени какой компании отправляется документ на подпись. */ +export interface ISigningTarget { + iin: string; + name: IFioParts; + /** БИН компании, от имени которой документ идёт в Sigex. */ + companyBin: string; + companyName: string; +} + +/** + * Определяет подписанта и компанию по данным справочников api.tnsu.kz: + * ИИН и ФИО — из Employee/ByEmail, БИН — из Dictionary/Companies по CompanyId сотрудника. + * + * Сотрудник может состоять в нескольких организациях (на один e-mail — несколько записей). + * Берём первую запись, у которой компания известна справочнику и в нём есть БИН: заявление + * от имени компании без БИН в ЭДО не принять. Каждая невозможность — отдельное сообщение, + * чтобы по тексту было ясно, что поправить: карточку сотрудника или справочник компаний. + */ +export function resolveSigningTarget(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTarget { + if (records.length === 0) { + throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда'); + } + + const withCompany = records.filter((r) => r.companyId); + if (withCompany.length === 0) { + throw new Error('в карточке сотрудника не указана компания'); + } + + const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c])); + const match = withCompany + .map((record) => ({ record, company: byId.get(record.companyId.toLowerCase()) })) + .find((m) => m.company && m.company.Bin && m.company.Bin.trim().length > 0); + + if (!match || !match.company) { + throw new Error(`в справочнике компаний нет БИН для «${withCompany[0].company}»`); + } + + const iin = (match.record.iin || '').trim(); + if (!iin) { + throw new Error('в кадровом справочнике у сотрудника не указан ИИН'); + } + + const name = splitFio(match.record.fio); + if (!name) { + throw new Error(`не удалось разобрать Ф.И.О. «${match.record.fio}» на фамилию и имя`); + } + + return { iin, name, companyBin: (match.company.Bin as string).trim(), companyName: match.company.Name }; +}