Feature/vacation edo signing #23

Merged
gorbenko.pavel merged 27 commits from feature/vacation-edo-signing into main 2026-10-05 08:53:19 +00:00
20 changed files with 777 additions and 10 deletions
Showing only changes of commit bed190835e - Show all commits
+1
View File
@@ -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"
+1
View File
@@ -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"
@@ -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" }
}]
}
@@ -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<IRequest
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE;
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
this._applyNotificationSettings();
const element: React.ReactElement = React.createElement(
@@ -89,6 +99,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
// панели без перезапуска), но render случается позже onInit.
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE;
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
this._applyNotificationSettings();
// Партиции грузим здесь, а не по требованию из форм: имена списков нужны
@@ -218,6 +230,19 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
})
]
},
{
groupName: 'ЭДО (подписание)',
groupFields: [
PropertyPaneDropdown('edoApiBaseUrl', {
label: 'С каким ЭДО работать',
selectedKey: this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE,
options: [
{ key: EDO_API_BASE_URL_STAGE, text: `Стейдж (${EDO_API_BASE_URL_STAGE})` },
{ key: EDO_API_BASE_URL_LOCAL, text: `Локально (${EDO_API_BASE_URL_LOCAL})` }
]
})
]
},
{
groupName: 'Уведомления',
groupFields: [
@@ -28,6 +28,24 @@
и пишется в колонку `EndDate` (обязательна в списке, но в UI её нет).
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
## Подписание в ЭДО
После того как все согласующие согласовали (статус заявки становится «Согласовано»), из браузера
**последнего согласующего** (`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО:
- **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как
получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан.
- **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам
переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба.
- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компании** — из `GET /Dictionary/Companies`
по `CompanyId` сотрудника (`shared/edo/signingTarget.ts`). Если сотрудник состоит в нескольких
организациях, берётся первая, у которой в справочнике есть БИН.
- **С каким ЭДО работать** — свойство веб-части «С каким ЭДО работать» (`edoApiBaseUrl`): стейдж или
локальный бэкенд. По умолчанию стейдж. Токен берётся для того же AAD-приложения, что у approval-route.
- **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом
кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после
перезагрузки страницы повтор недоступен.
- **Ограничение:** срабатывает только если финальное решение принято в UI портала. Автосогласование по
SLA на бэкенде approval-route этот код не вызывает — такое заявление на подпись не уйдёт.
## Секции формы
RequestSection (сотрудник · должность · дата начала · количество дней)
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import type { WebPartContext } from '@microsoft/sp-webpart-base';
import styles from './VacationView.module.scss';
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
import {
ApproverService,
DecisionPanel,
@@ -13,6 +13,7 @@ import {
notifyActiveStep,
useRequestTypeId
} from '../../shared/approval';
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
import { friendlyErrorMessage } from '../../shared/diagnostics';
import {
approvalListTitle,
@@ -23,6 +24,7 @@ import {
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
import { loadVacationRequest } from './load';
import { vacationApprovalService } from './submit';
import { sendVacationForSigning } from './sendToSign';
import { RequestSection } from './sections/RequestSection';
import { useStableCallback } from '../../shared/useStableCallback';
@@ -52,11 +54,17 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
const [data, setData] = useState<IFormData>(EMPTY_DATA);
const [status, setStatus] = useState<string>('');
const [code, setCode] = useState<string>('');
// Ф.И.О. из заявки (Employee/Title) — запасной вариант для документа на подпись.
const [employeeName, setEmployeeName] = useState<string>('');
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
// Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning).
const [signingInfo, setSigningInfo] = useState<string>('');
const [signingError, setSigningError] = useState<string>('');
const [signingBusy, setSigningBusy] = useState<boolean>(false);
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
// vacationApprovalService можно звать не опасаясь исключения.
@@ -84,6 +92,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ 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<IVacationViewProps> = ({ context, itemId, formKey,
}).catch(() => undefined);
};
// Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск.
// Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным
// сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта:
// признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем.
const sendToSigning = (): Promise<void> => {
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 (
<div>
<div className={`${styles.toolbar} rp-no-print`}>
@@ -160,6 +190,15 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
</div>
<div className="rp-no-print">
{signingInfo && <Alert variant="ok">{signingInfo}</Alert>}
{signingError && (
<>
<Alert variant="err">{signingError}</Alert>
<Button variant="secondary" onClick={() => sendToSigning().catch(() => undefined)} disabled={signingBusy}>
Повторить отправку на подпись
</Button>
</>
)}
<DecisionPanel
service={approverService}
requestId={itemId}
@@ -168,6 +207,10 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
setStatus(newStatus);
setDecisionSeq((n) => n + 1);
notify();
// «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет).
// Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись.
return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined;
}}
onDelegated={() => {
setDecisionSeq((n) => n + 1);
@@ -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;
}
@@ -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<string> {
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<IEdoSigningResult> {
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 }
});
}
@@ -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();
@@ -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<ArrayBuffer> {
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('ТОО &quot;Рога &amp; Копыта&quot; &lt;филиал&gt;');
expect(xml).not.toContain('Рога & Копыта');
});
it('экранирование', () => {
expect(escapeXml(`<a href="x">&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;');
});
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('ЗАЯВЛЕНИЕ');
});
});
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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 ? `<w:ind w:firstLine="${firstLine}"/>` : '';
const run =
`<w:rPr><w:rFonts w:ascii="${FONT}" w:hAnsi="${FONT}" w:cs="${FONT}"/>` +
`${bold ? '<w:b/>' : ''}<w:sz w:val="${FONT_SIZE_HALF_POINTS}"/><w:szCs w:val="${FONT_SIZE_HALF_POINTS}"/></w:rPr>`;
return (
`<w:p><w:pPr><w:spacing w:after="${after}" w:line="276" w:lineRule="auto"/>${indent}<w:jc w:val="${align}"/></w:pPr>` +
`<w:r>${run}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`
);
}
/** Абзацы заявления. Формулировка — рабочий вариант, финальный текст утверждает кадровая служба. */
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 =
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
'<w:pgMar w:top="1134" w:right="850" w:bottom="1134" w:left="1701" w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>';
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
`<w:body>${body}${section}</w:body></w:document>`
);
}
const CONTENT_TYPES_XML =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
'<Default Extension="xml" ContentType="application/xml"/>' +
'<Override PartName="/word/document.xml" ' +
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
'</Types>';
const ROOT_RELS_XML =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
'<Relationship Id="rId1" ' +
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' +
'Target="word/document.xml"/></Relationships>';
/**
* Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса
* документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием.
*/
export async function createVacationDocx(input: IVacationDocumentInput): Promise<Blob> {
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`;
}
@@ -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 выше.
@@ -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) || '',
@@ -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;
}
@@ -8,6 +8,8 @@ export interface IEmployeeOrganization {
id: string;
fio: string;
email: string;
/** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */
iin: string;
company: string;
companyId: string;
section: string;
@@ -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<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);
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 || '' }))
};
}
@@ -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> = {}): 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, '<html>Bad gateway</html>')).toBe('ЭДО ответил 502');
expect(describeEdoError(401, '')).toContain('401');
expect(describeEdoError(403, '')).toContain('403');
});
});
@@ -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<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,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 };
}