feat: заявление на отпуск уходит на подпись в ЭДО после финального согласования
Когда последний согласующий согласовывает заявку на отпуск, заявление собирается в браузере как DOCX и отправляется в ЭДО (POST /api/v1/integration/signing-requests). Подписывает сотрудник из заявки, его же почта передаётся как initiator_email. ИИН берётся из Employee/ByEmail, БИН компании — из Dictionary/Companies. Неудача отправки не отменяет согласование и показывается сообщением с кнопкой повтора. Свойство веб-части «С каким ЭДО работать»: стейдж (https://10.0.1.225:8443) или локальный бэкенд (https://localhost:7016). jszip объявлен зависимостью явно (раньше был транзитивным). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -18,6 +18,7 @@
|
|||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.12.0",
|
||||||
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@pnp/sp": "^4.20.0",
|
"@pnp/sp": "^4.20.0",
|
||||||
"@pnp/spfx-controls-react": "^3.25.0",
|
"@pnp/spfx-controls-react": "^3.25.0",
|
||||||
"@tnsu/ui-kit": "^0.12.0",
|
"@tnsu/ui-kit": "^0.12.0",
|
||||||
|
"jszip": "~3.8.0",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"tslib": "2.3.1"
|
"tslib": "2.3.1"
|
||||||
|
|||||||
@@ -16,6 +16,6 @@
|
|||||||
"title": { "default": "request-portal" },
|
"title": { "default": "request-portal" },
|
||||||
"description": { "default": "request-portal description" },
|
"description": { "default": "request-portal description" },
|
||||||
"officeFabricIconFontName": "Page",
|
"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 {
|
import {
|
||||||
APPROVAL_API_BASE_URL_LOCAL,
|
APPROVAL_API_BASE_URL_LOCAL,
|
||||||
APPROVAL_API_BASE_URL_PROD,
|
APPROVAL_API_BASE_URL_PROD,
|
||||||
|
EDO_API_BASE_URL_LOCAL,
|
||||||
|
EDO_API_BASE_URL_STAGE,
|
||||||
setApprovalApiBaseUrl,
|
setApprovalApiBaseUrl,
|
||||||
|
setEdoApiBaseUrl,
|
||||||
setNotificationSettings
|
setNotificationSettings
|
||||||
} from './shared/config';
|
} from './shared/config';
|
||||||
import { initListPartitions } from './shared/approval';
|
import { initListPartitions } from './shared/approval';
|
||||||
@@ -38,6 +41,11 @@ export interface IRequestPortalWebPartProps {
|
|||||||
debugUserDisplayName: string;
|
debugUserDisplayName: string;
|
||||||
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
/** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */
|
||||||
approvalApiBaseUrl: string;
|
approvalApiBaseUrl: string;
|
||||||
|
/**
|
||||||
|
* С каким ЭДО работаем при отправке документов на подписание — локальным или
|
||||||
|
* стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж.
|
||||||
|
*/
|
||||||
|
edoApiBaseUrl: string;
|
||||||
/**
|
/**
|
||||||
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
* Уведомления согласующим (поток Power Automate). По умолчанию включены:
|
||||||
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
* у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл».
|
||||||
@@ -58,6 +66,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
setImpersonatedIdentity(this.properties.debugUserQuery, this.properties.debugUserDisplayName);
|
||||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||||
|
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE;
|
||||||
|
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
|
||||||
this._applyNotificationSettings();
|
this._applyNotificationSettings();
|
||||||
|
|
||||||
const element: React.ReactElement = React.createElement(
|
const element: React.ReactElement = React.createElement(
|
||||||
@@ -89,6 +99,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart<IRequest
|
|||||||
// панели без перезапуска), но render случается позже onInit.
|
// панели без перезапуска), но render случается позже onInit.
|
||||||
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
this.properties.approvalApiBaseUrl = this.properties.approvalApiBaseUrl || APPROVAL_API_BASE_URL_PROD;
|
||||||
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
setApprovalApiBaseUrl(this.properties.approvalApiBaseUrl);
|
||||||
|
this.properties.edoApiBaseUrl = this.properties.edoApiBaseUrl || EDO_API_BASE_URL_STAGE;
|
||||||
|
setEdoApiBaseUrl(this.properties.edoApiBaseUrl);
|
||||||
this._applyNotificationSettings();
|
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: 'Уведомления',
|
groupName: 'Уведомления',
|
||||||
groupFields: [
|
groupFields: [
|
||||||
|
|||||||
@@ -28,6 +28,24 @@
|
|||||||
и пишется в колонку `EndDate` (обязательна в списке, но в UI её нет).
|
и пишется в колонку `EndDate` (обязательна в списке, но в UI её нет).
|
||||||
- **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`.
|
- **Заголовок элемента** (`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 (сотрудник · должность · дата начала · количество дней)
|
RequestSection (сотрудник · должность · дата начала · количество дней)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import styles from './VacationView.module.scss';
|
import styles from './VacationView.module.scss';
|
||||||
import { Alert, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
import { Alert, Button, FormCard, InitiatorLine, FormModeProvider, PrintSplitButton, OrgLetterhead } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
ApproverService,
|
ApproverService,
|
||||||
DecisionPanel,
|
DecisionPanel,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
notifyActiveStep,
|
notifyActiveStep,
|
||||||
useRequestTypeId
|
useRequestTypeId
|
||||||
} from '../../shared/approval';
|
} from '../../shared/approval';
|
||||||
|
import { REQUEST_STATUS } from '../../shared/approval/models/statuses';
|
||||||
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
import { friendlyErrorMessage } from '../../shared/diagnostics';
|
||||||
import {
|
import {
|
||||||
approvalListTitle,
|
approvalListTitle,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { vacationApprovalService } from './submit';
|
import { vacationApprovalService } from './submit';
|
||||||
|
import { sendVacationForSigning } from './sendToSign';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { useStableCallback } from '../../shared/useStableCallback';
|
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 [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
|
// Ф.И.О. из заявки (Employee/Title) — запасной вариант для документа на подпись.
|
||||||
|
const [employeeName, setEmployeeName] = useState<string>('');
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
// Какая из организаций сотрудника (см. OrganizationField) идёт в печать —
|
||||||
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
// выбор в тулбаре через PrintSplitButton, по умолчанию первая.
|
||||||
const [printOrgIndex, setPrintOrgIndex] = useState<number>(0);
|
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 (не смонтировались бы сюда иначе) —
|
// listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) —
|
||||||
// vacationApprovalService можно звать не опасаясь исключения.
|
// vacationApprovalService можно звать не опасаясь исключения.
|
||||||
@@ -84,6 +92,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
|
setEmployeeName(loaded.employeeName);
|
||||||
notifyCode(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -125,6 +134,27 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
}).catch(() => undefined);
|
}).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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${styles.toolbar} rp-no-print`}>
|
<div className={`${styles.toolbar} rp-no-print`}>
|
||||||
@@ -160,6 +190,15 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rp-no-print">
|
<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
|
<DecisionPanel
|
||||||
service={approverService}
|
service={approverService}
|
||||||
requestId={itemId}
|
requestId={itemId}
|
||||||
@@ -168,6 +207,10 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
setDecisionSeq((n) => n + 1);
|
setDecisionSeq((n) => n + 1);
|
||||||
notify();
|
notify();
|
||||||
|
// «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет).
|
||||||
|
// Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение
|
||||||
|
// сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись.
|
||||||
|
return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined;
|
||||||
}}
|
}}
|
||||||
onDelegated={() => {
|
onDelegated={() => {
|
||||||
setDecisionSeq((n) => n + 1);
|
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 { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval';
|
||||||
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
import { ISaveItemResult, saveRequestItem } from '../../shared/dataList';
|
||||||
import { ensureEmployee } from '../../shared/employee';
|
import { ensureEmployee } from '../../shared/employee';
|
||||||
|
import { computeEndDate } from './dates';
|
||||||
import { FORM_ID, IFormData } from './model';
|
import { FORM_ID, IFormData } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,13 +17,6 @@ function pad2(n: number): string {
|
|||||||
return n < 10 ? `0${n}` : `${n}`;
|
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). */
|
/** Осмысленный заголовок элемента (системная колонка Title). */
|
||||||
function buildTitle(employeeName: string): string {
|
function buildTitle(employeeName: string): string {
|
||||||
const now = new Date();
|
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('ТОО "Рога & Копыта" <филиал>');
|
||||||
|
expect(xml).not.toContain('Рога & Копыта');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('экранирование', () => {
|
||||||
|
expect(escapeXml(`<a href="x">&'</a>`)).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('ЗАЯВЛЕНИЕ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, '"')
|
||||||
|
.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 ? `<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;
|
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).
|
// Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts).
|
||||||
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
// Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот
|
||||||
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
// же модульный синглтон, что и у approvalApiBaseUrl выше.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ async function fetchEmployeeOrganizations(context: WebPartContext, email: string
|
|||||||
id: (r.Id as string) || '',
|
id: (r.Id as string) || '',
|
||||||
fio: (r.Fio as string) || '',
|
fio: (r.Fio as string) || '',
|
||||||
email: (r.Email as string) || '',
|
email: (r.Email as string) || '',
|
||||||
|
iin: (r.Iin as string) || '',
|
||||||
company: (r.Company as string) || '',
|
company: (r.Company as string) || '',
|
||||||
companyId: (r.CompanyId as string) || '',
|
companyId: (r.CompanyId as string) || '',
|
||||||
section: (r.Section 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).
|
// (PascalCase, без обёртки — как /Dictionary/Departments).
|
||||||
export interface ICompany {
|
export interface ICompany {
|
||||||
/** GUID компании — стабильный идентификатор. */
|
/** GUID компании — стабильный идентификатор. */
|
||||||
Id: string;
|
Id: string;
|
||||||
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
/** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */
|
||||||
Name: string;
|
Name: string;
|
||||||
|
/** БИН компании, 12 цифр. Нужен для ЭДО (от имени какой компании подписывается документ). */
|
||||||
|
Bin?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface IEmployeeOrganization {
|
|||||||
id: string;
|
id: string;
|
||||||
fio: string;
|
fio: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
/** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */
|
||||||
|
iin: string;
|
||||||
company: string;
|
company: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
section: 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user