feat: подписание пакета INCORE через NCALayer и карточку SIGEX

Кнопка «Выгрузить пакет документов для INCORE MANAGEMENT» на шаге 4 теперь
не просто скачивает отчёт, а формирует юридически значимую карточку
электронного документа (DDC) с ЭЦП инициатора.

Флоу: отчёт СТ в PDF → подпись через десктопный NCALayer (CMS) →
регистрация в SIGEX (POST /api) → фиксация хэша (POST /api/{id}/data) →
сборка карточки (POST /api/{id}/buildDDC, qrWithIDLink=true) → карточка
с QR-проверкой скачивается в пакет вместе с файлами КП.

- Новый SigexSignService: NCALayer basicsSignCMS + SIGEX register/finalize/
  buildDDC + оркестратор signAndBuildCard с колбэком прогресса.
- CtService: getCtReportBlob() (blob вместо скачивания) + downloadBlob().
- useStep4Approval/Step4Approval: прогресс-диалог по этапам подписания
  и диалог ошибки; отмена в окне NCALayer не считается ошибкой.
- ncalayer-js-client + ambient-типы (src/global.d.ts).

Подписывает только инициатор, только через NCALayer (без eGov mobile/QR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-02 11:38:00 +05:00
parent c1a5c6fde7
commit 158c007c15
8 changed files with 304 additions and 8 deletions
+10 -1
View File
@@ -16,6 +16,7 @@
"@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0",
"ncalayer-js-client": "^1.6.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"tslib": "2.3.1"
@@ -15087,6 +15088,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ncalayer-js-client": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ncalayer-js-client/-/ncalayer-js-client-1.6.0.tgz",
"integrity": "sha512-poSneFadh+I8630ZmycLhKPZTn7Gqju0/TiMmWXnqEsG0mQKrEH7ZbeLwYrynryotReHnLYTA5VLZr8TnE3X5w==",
"license": "MIT",
"dependencies": {
"ws": "^8.4.2"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -19960,7 +19970,6 @@
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+1
View File
@@ -20,6 +20,7 @@
"@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0",
"ncalayer-js-client": "^1.6.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"tslib": "2.3.1"
+39
View File
@@ -0,0 +1,39 @@
// Ambient-типы для NCALayer JS client (у пакета нет собственных .d.ts).
// Используется только CMS-подписание через десктопный NCALayer (см. SigexSignService).
declare module 'ncalayer-js-client' {
export class NCALayerError extends Error {
canceledByUser?: boolean;
}
export interface CMSParams {
decode?: boolean;
encapsulate?: boolean;
digested?: boolean;
tsaProfile?: object;
}
export interface SignerParams {
extKeyUsageOids: string[];
chain?: unknown[];
}
export class NCALayerClient {
constructor(url?: string, allowKmdHttpApi?: boolean);
static basicsStorageAll: null;
static basicsCMSParamsDetachedNoTSP: CMSParams;
static basicsSignerSignAny: SignerParams;
connect(): Promise<string>;
basicsSignCMS(
allowedStorages: string[] | null,
data: string | ArrayBuffer | Blob | File,
signingParams: CMSParams,
signerParams: SignerParams,
locale?: string,
): Promise<string>;
}
}
@@ -265,3 +265,17 @@
.dialogError {
margin-top: 12px;
}
.incoreProgress {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
font-size: 14px;
}
.incoreHint {
margin: 8px 0 0;
font-size: 13px;
color: $muted;
}
@@ -3,7 +3,7 @@ import { Button, Alert, Spinner, IconButton, Dialog, TextInput, TextArea, Icon,
import { ApprovalMemberStatus, FormStep } from "../types/domain";
import { CtService } from "../../services/CtService";
import { CTFormData } from "../hooks/useCtForm";
import { useStep4Approval } from "./useStep4Approval";
import { useStep4Approval, IncoreStep } from "./useStep4Approval";
import styles from "./Step4Approval.module.scss";
interface Props {
@@ -23,6 +23,15 @@ const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
skipped: "Пропущено",
};
const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
report: "Формирование отчёта СТ…",
connecting: "Подключение к NCALayer…",
signing: "Ожидание подписи в NCALayer…",
registering: "Регистрация документа в SIGEX…",
finalizing: "Фиксация документа…",
building: "Формирование карточки с ЭЦП…",
};
function getStatusClass(status: ApprovalMemberStatus): string {
const map: Record<ApprovalMemberStatus, string> = {
pending: styles.statusPending,
@@ -379,6 +388,42 @@ export const Step4Approval: React.FC<Props> = (props) => {
)}
</Dialog>
)}
{/* INCORE: прогресс подписания и сборки карточки */}
{a.incoreStep && (
<Dialog
open
title="Подписание и выгрузка в INCORE"
onDismiss={() => undefined}
>
<div className={styles.incoreProgress}>
<Spinner size="small" />
<span>{INCORE_STEP_LABELS[a.incoreStep]}</span>
</div>
{a.incoreStep === "signing" && (
<p className={styles.incoreHint}>
Выберите сертификат ЭЦП и введите PIN-код в открывшемся окне
NCALayer.
</p>
)}
</Dialog>
)}
{/* INCORE: ошибка подписания */}
{a.incoreError && (
<Dialog
open
title="Ошибка выгрузки в INCORE"
onDismiss={a.dismissIncoreError}
footer={
<Button variant="secondary" onClick={a.dismissIncoreError}>
Закрыть
</Button>
}
>
<Alert variant="err">{a.incoreError}</Alert>
</Dialog>
)}
</div>
);
};
@@ -1,8 +1,15 @@
import { useState, useCallback, useRef } from 'react';
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService';
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
import { CTFormData } from '../hooks/useCtForm';
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
export type IncoreStep = 'report' | SigexSignStep;
// Подпись через SIGEX без состояния — один экземпляр на модуль.
const sigexSign = new SigexSignService();
export type ApprovalAction = 'approve' | 'reject';
export interface ActionDialogState {
@@ -44,6 +51,8 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
const [submitError, setSubmitError] = useState<string | null>(null);
const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
const [isSendingToIncore, setIsSendingToIncore] = useState(false);
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
const [incoreError, setIncoreError] = useState<string | null>(null);
// ─── Add member ───
const [showAddRow, setShowAddRow] = useState(false);
@@ -324,9 +333,22 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
const handleSendToIncore = async (): Promise<void> => {
if (!ct) return;
setIsSendingToIncore(true);
setSubmitError(null);
setIncoreError(null);
try {
await service.generateCtReport(ct.id);
// 1. Отчёт СТ в PDF — карточка SIGEX визуализирует именно PDF.
setIncoreStep('report');
const reportBlob = await service.getCtReportBlob(ct.id, 'pdf');
const title = `CT_${ct.id}.pdf`;
// 2. Подпись NCALayer → регистрация → фиксация → карточка (DDC).
const cardBlob = await sigexSign.signAndBuildCard(
reportBlob,
title,
setIncoreStep,
);
// 3. Пакет для INCORE: карточка с ЭЦП + файлы КП.
service.downloadBlob(cardBlob, `Карточка_CT_${ct.id}.pdf`);
const kpFiles = formData.uploadedKpFiles ?? [];
for (const f of kpFiles) {
@@ -334,8 +356,13 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
await new Promise<void>((resolve) => setTimeout(resolve, 300));
}
} catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка скачивания файлов');
// Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
setIncoreError(
(err as Error).message ?? 'Ошибка подписания и выгрузки пакета',
);
} finally {
setIncoreStep(null);
setIsSendingToIncore(false);
}
};
@@ -384,6 +411,9 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
dismissNotifyWarning: () => setNotifyWarning(null),
isSubmitting,
isSendingToIncore,
incoreStep,
incoreError,
dismissIncoreError: () => setIncoreError(null),
isOnApproval,
showStatuses,
isInitiator,
@@ -1505,7 +1505,9 @@ export class CtService {
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`);
}
async generateCtReport(ctId: string, format: string = "xlsx"): Promise<void> {
// Сформировать отчёт СТ и вернуть его как blob (без скачивания на диск).
// Нужен для подписания через NCALayer и сборки карточки SIGEX.
async getCtReportBlob(ctId: string, format: string = "xlsx"): Promise<Blob> {
const token = await this._getApiToken();
const res = await fetch(
`${EXTERNAL_API}/Report/CT?ctid=${parseInt(ctId, 10)}&format=${format}`,
@@ -1514,11 +1516,20 @@ export class CtService {
},
);
if (!res.ok) throw new Error(`Ошибка формирования отчёта: ${res.status}`);
const blob = await res.blob();
return res.blob();
}
async generateCtReport(ctId: string, format: string = "xlsx"): Promise<void> {
const blob = await this.getCtReportBlob(ctId, format);
this.downloadBlob(blob, `CT_${ctId}.${format}`);
}
// Скачать blob как файл под указанным именем.
downloadBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `CT_${ctId}.${format}`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -0,0 +1,147 @@
import { NCALayerClient } from 'ncalayer-js-client';
// Этапы подписания — для отображения прогресса в UI.
export type SigexSignStep =
| 'connecting' // подключение к NCALayer
| 'signing' // ожидание выбора сертификата и PIN в окне NCALayer
| 'registering' // регистрация документа в SIGEX
| 'finalizing' // фиксация хэша документа
| 'building'; // сборка карточки электронного документа
// Официальный сервис проверки/регистрации ЭЦП (карточка = DDC).
// API: https://sigex.kz/en/support/developers/api-documents/
const SIGEX_BASE_URL = 'https://sigex.kz';
interface RegisterResponse {
documentId?: string;
message?: string;
}
interface BuildDdcResponse {
ddc?: string; // карточка электронного документа в виде base64-строки
message?: string;
}
// Подписание PDF через десктопный NCALayer и формирование карточки
// электронного документа (DDC) в SIGEX. Мобильный/QR-сценарий не используется —
// подпись только ключом ЭЦП на ПК подписанта.
export class SigexSignService {
// Подписать PDF (base64) — CMS detached без метки времени, ключ SIGN.
async signWithNcaLayer(pdfBase64: string): Promise<string> {
const client = new NCALayerClient();
await client.connect();
return client.basicsSignCMS(
NCALayerClient.basicsStorageAll,
pdfBase64,
NCALayerClient.basicsCMSParamsDetachedNoTSP,
NCALayerClient.basicsSignerSignAny,
);
}
// Зарегистрировать документ в SIGEX вместе с первой подписью → documentId.
async registerDocument(title: string, cmsBase64: string): Promise<string> {
const res = await fetch(`${SIGEX_BASE_URL}/api`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
signType: 'cms',
signature: cmsBase64,
settings: {},
}),
});
const data = (await res.json()) as RegisterResponse;
if (!res.ok || !data.documentId) {
throw new Error(`SIGEX: ${data.message ?? `HTTP ${res.status}`}`);
}
return data.documentId;
}
// Зафиксировать хэш документа — обязательный шаг перед сборкой карточки.
async finalizeDocument(documentId: string, pdfBlob: Blob): Promise<void> {
const res = await fetch(`${SIGEX_BASE_URL}/api/${documentId}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: pdfBlob,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`SIGEX (фиксация документа): ${res.status} ${text}`);
}
}
// Собрать карточку электронного документа (DDC) — PDF с оригиналом, подписью
// и визуализацией. qrWithIDLink=true — QR со ссылкой на страницу проверки.
async buildDdc(documentId: string, pdfBlob: Blob, fileName: string): Promise<Blob> {
const query = new URLSearchParams({
fileName,
qrWithIDLink: 'true',
language: 'ru',
});
const res = await fetch(
`${SIGEX_BASE_URL}/api/${documentId}/buildDDC?${query.toString()}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: pdfBlob,
},
);
const data = (await res.json()) as BuildDdcResponse;
if (!res.ok || !data.ddc) {
throw new Error(`SIGEX (сборка карточки): ${data.message ?? `HTTP ${res.status}`}`);
}
return this._base64ToPdfBlob(data.ddc);
}
// Полный сценарий: подпись PDF → регистрация → фиксация → карточка.
// onProgress уведомляет UI о текущем этапе.
async signAndBuildCard(
pdfBlob: Blob,
title: string,
onProgress: (step: SigexSignStep) => void,
): Promise<Blob> {
const pdfBase64 = await this._blobToBase64(pdfBlob);
onProgress('connecting');
const client = new NCALayerClient();
await client.connect();
onProgress('signing');
const cms = await client.basicsSignCMS(
NCALayerClient.basicsStorageAll,
pdfBase64,
NCALayerClient.basicsCMSParamsDetachedNoTSP,
NCALayerClient.basicsSignerSignAny,
);
onProgress('registering');
const documentId = await this.registerDocument(title, cms);
onProgress('finalizing');
await this.finalizeDocument(documentId, pdfBlob);
onProgress('building');
return this.buildDdc(documentId, pdfBlob, title);
}
private async _blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
private _base64ToPdfBlob(base64: string): Blob {
const normalized = base64.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: 'application/pdf' });
}
}