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:
Generated
+10
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+39
@@ -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>;
|
||||
}
|
||||
}
|
||||
+14
@@ -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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user