Compare commits

...

3 Commits

Author SHA1 Message Date
Тимур Городецкий f615ca3c95 fix: наезд подзаголовков таблицы сравнения на первую строку
Второй ряд шапки (Наименование ТМЦ/Ед.изм./Кол-во/Цена/Сумма) прибит
sticky с захардкоженным top:70px, а реальная высота ряда поставщика больше
(имя, индикатор Adata, теги валют, статус, кнопки) — из-за чего подзаголовки
наезжали на первую строку данных.

Высота row1 теперь измеряется через ResizeObserver и прокидывается в CSS
переменную --header-row1-h; sticky top использует её. Обновляется при
выборе поставщика, появлении тегов валют и изменении состояния Adata.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 11:38:58 +05:00
Тимур Городецкий 158c007c15 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>
2026-09-02 11:38:00 +05:00
Тимур Городецкий c1a5c6fde7 fix: сохранение данных шагов 2 и 4 при навигации хлебными крошками
Навигация через goToStep (хлебные крошки) переключает шаг, не вызывая
per-step save, поэтому локальный стейт, который писался в formData только
по кнопке «Сохранить и продолжить», терялся при переходе на другой шаг.

- Шаг 2: поставщики без КП теперь гидратируются из formData и пишутся
  в formData.suppliers сразу при изменении (а не только в persistStep2).
- Шаг 2: курсы валют пишутся в formData при каждой правке.
- Шаг 2: поставщик с КП и его офферы (с валютой файла) пишутся в formData
  инкрементально при завершении обработки КП, иначе загруженный после
  первого сохранения поставщик не попадал колонкой в таблицу шага 3.
  Построение поставщиков/офферов вынесено в общую buildKpSuppliersAndOffers.
- Шаг 4: переупорядочивание согласующих (moveMember) пишет порядок
  в formData сразу, иначе он сбрасывался при переходе на другой шаг.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-02 11:37:20 +05:00
13 changed files with 444 additions and 67 deletions
+10 -1
View File
@@ -16,6 +16,7 @@
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0", "@tnsu/ui-kit": "^0.11.0",
"ncalayer-js-client": "^1.6.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"
@@ -15087,6 +15088,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -19960,7 +19970,6 @@
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
+1
View File
@@ -20,6 +20,7 @@
"@microsoft/sp-property-pane": "1.22.1", "@microsoft/sp-property-pane": "1.22.1",
"@microsoft/sp-webpart-base": "1.22.1", "@microsoft/sp-webpart-base": "1.22.1",
"@tnsu/ui-kit": "^0.11.0", "@tnsu/ui-kit": "^0.11.0",
"ncalayer-js-client": "^1.6.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"
+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>;
}
}
@@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Alert, Spinner } from '@tnsu/ui-kit'; import { Button, Alert, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate, FormStep } from '../types/domain'; import { CurrencyRate, FormStep, Supplier } from '../types/domain';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { FileUploadArea, UploadedFile } from './FileUploadArea'; import { FileUploadArea, UploadedFile } from './FileUploadArea';
@@ -41,12 +41,39 @@ export const Step2CommercialOffers: React.FC<Props> = ({
isRestrictedView = false, isRestrictedView = false,
}) => { }) => {
const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>(formData.currencyRates); const [currencyRates, setCurrencyRates] = useState<CurrencyRate[]>(formData.currencyRates);
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>([]); // Гидратируем из уже сохранённых поставщиков без КП, чтобы список не был пустым при возврате на шаг.
const [noOfferSuppliers, setNoOfferSuppliers] = useState<NoOfferEntry[]>(() =>
(formData.suppliers ?? [])
.filter(s => !s.hasOffer)
.map(s => ({ localId: s.id, name: s.name, comment: '' })),
);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | undefined>(undefined); const [saveError, setSaveError] = useState<string | undefined>(undefined);
const kp = useKpProcessing(service, formData, onSave); const kp = useKpProcessing(service, formData, onSave);
// Курсы валют пишем в formData сразу, а не только по кнопке «Сохранить и продолжить»:
// иначе при переходе на Шаг 3 через хлебные крошки (в обход persistStep2) правка курса
// теряется, и таблица сравнения считает суммы по старому курсу.
const handleCurrencyRatesChange = (rates: CurrencyRate[]): void => {
setCurrencyRates(rates);
onSave({ currencyRates: rates });
};
// Поставщиков без КП сразу пишем в formData.suppliers (hasOffer:false), а не только по кнопке
// «Сохранить и продолжить». Иначе при переходе на Шаг 3 через хлебные крошки (goToStep в обход
// persistStep2) колонка поставщика без КП теряется и не попадает в таблицу сравнения.
const handleNoOfferChange = (items: NoOfferEntry[]): void => {
setNoOfferSuppliers(items);
const withOffer = (formData.suppliers ?? []).filter(s => s.hasOffer);
const withoutOffer: Supplier[] = items.map(s => ({
id: s.localId,
name: s.name,
hasOffer: false,
}));
onSave({ suppliers: [...withOffer, ...withoutOffer] });
};
const handleSave = async (): Promise<void> => { const handleSave = async (): Promise<void> => {
setIsSaving(true); setIsSaving(true);
setSaveError(undefined); setSaveError(undefined);
@@ -98,11 +125,11 @@ export const Step2CommercialOffers: React.FC<Props> = ({
<section className={`${styles.section} ${styles.currencySection}`}> <section className={`${styles.section} ${styles.currencySection}`}>
<h3 className={styles.sectionTitle}>Курсы валют</h3> <h3 className={styles.sectionTitle}>Курсы валют</h3>
<CurrencyRatesForm rates={currencyRates} onChange={setCurrencyRates} readOnly={isReadOnly} /> <CurrencyRatesForm rates={currencyRates} onChange={handleCurrencyRatesChange} readOnly={isReadOnly} />
</section> </section>
</div> </div>
<SuppliersWithoutKp items={noOfferSuppliers} onChange={setNoOfferSuppliers} disabled={isReadOnly} /> <SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && ( {!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
<UploadedKpList <UploadedKpList
@@ -12,6 +12,55 @@ export interface PersistStep2Args {
userDisplayName: string; userDisplayName: string;
} }
// Строит поставщиков с КП и их офферы из результатов потока (Syntex). Вынесено из persistStep2,
// чтобы ту же логику использовал инкрементальный persist в useKpProcessing (поставщик должен
// доходить до Шага 3 даже без кнопки «Сохранить и продолжить»).
export function buildKpSuppliersAndOffers(
kpFiles: KpFile[],
currencyRates: CurrencyRate[],
): { suppliers: Supplier[]; offers: SupplierOffer[] } {
const suppliers: Supplier[] = kpFiles
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
}));
const offers: SupplierOffer[] = [];
for (const f of kpFiles) {
if (!f.supplierSpId || f.flowResults.length === 0) continue;
const currency = f.currencyCode;
const rate = currency !== 'KZT'
? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
for (const fr of f.flowResults) {
const pricePerUnit = fr.flowResult.price ?? 0;
const quantity = fr.flowResult.quantity ?? 0;
const pricePerUnitKzt = pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
offers.push({
id: '',
supplierId: String(f.supplierSpId),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: f.spId ? String(f.spId) : undefined,
});
}
}
return { suppliers, offers };
}
// Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока, // Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave. // пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> { export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFormData>> {
@@ -26,15 +75,7 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
await service.saveNoOfferSupplierItem(s.name, s.comment); await service.saveNoOfferSupplierItem(s.name, s.comment);
} }
const newSuppliersWithOffer: Supplier[] = kpFiles const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
.filter(f => f.supplierName)
.map(f => ({
id: f.supplierSpId ? String(f.supplierSpId) : f.localId,
spId: f.supplierSpId,
name: f.supplierName as string,
hasOffer: true,
currency: f.currencyCode,
}));
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({ const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
id: s.localId, id: s.localId,
@@ -42,36 +83,6 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
hasOffer: false, hasOffer: false,
})); }));
// Build offers from flow results so Step 3 can pre-fill the table
const newOffers: SupplierOffer[] = [];
for (const f of kpFiles) {
if (!f.supplierSpId || f.flowResults.length === 0) continue;
const currency = f.currencyCode;
const rate = currency !== 'KZT'
? (savedRates.find(r => r.code === currency)?.rateToKzt ?? 1)
: 1;
for (const fr of f.flowResults) {
const pricePerUnit = fr.flowResult.price ?? 0;
const quantity = fr.flowResult.quantity ?? 0;
const pricePerUnitKzt = pricePerUnit * rate;
const totalPriceKzt = pricePerUnitKzt * quantity;
newOffers.push({
id: '',
supplierId: String(f.supplierSpId),
positionId: fr.positionId,
supplierPosition: fr.flowResult.position,
unit: fr.flowResult.unit,
pricePerUnit,
quantity,
currencyCode: currency,
pricePerUnitKzt,
totalPriceKzt,
totalWithExtraKzt: totalPriceKzt,
offerId: f.spId ? String(f.spId) : undefined,
});
}
}
// Persist offers with currency data to SP so shared links show correct currency columns. // Persist offers with currency data to SP so shared links show correct currency columns.
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here // saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
// before proceeding to Step 3. // before proceeding to Step 3.
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes'; import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
import { buildKpSuppliersAndOffers } from './step2Persist';
let localIdCounter = 0; let localIdCounter = 0;
@@ -34,17 +35,35 @@ export function useKpProcessing(
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]); useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
useEffect(() => () => { mountedRef.current = false; }, []); useEffect(() => () => { mountedRef.current = false; }, []);
// Сохраняем завершённые файлы в formData при изменении статуса // Сохраняем завершённые файлы в formData при изменении статуса: список загруженных КП, а также
// поставщиков с офферами. Иначе поставщик, чей КП загрузили после первого сохранения Шага 2,
// не доходит до formData.suppliers и не появляется колонкой в таблице сравнения на Шаге 3
// (переход крошкой минует persistStep2). Валюта, выбранная для файла, тоже попадает сюда.
useEffect(() => { useEffect(() => {
const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined); const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined);
if (doneFiles.length === 0) return; if (doneFiles.length === 0) return;
const currentUploaded = formDataRef.current.uploadedKpFiles ?? []; const fd = formDataRef.current;
const patch: Partial<CTFormData> = {};
const currentUploaded = fd.uploadedKpFiles ?? [];
const newEntries = doneFiles const newEntries = doneFiles
.filter(f => !currentUploaded.some(u => u.spId === f.spId)) .filter(f => !currentUploaded.some(u => u.spId === f.spId))
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName })); .map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
if (newEntries.length > 0) { if (newEntries.length > 0) {
onSave({ uploadedKpFiles: [...currentUploaded, ...newEntries] }); patch.uploadedKpFiles = [...currentUploaded, ...newEntries];
} }
const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates);
if (kpSuppliers.length > 0) {
// Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем.
const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name));
patch.suppliers = [...others, ...kpSuppliers];
const kpSupplierIds = new Set(kpSuppliers.map(s => s.id));
const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId));
patch.offers = [...otherOffers, ...kpOffers];
}
if (Object.keys(patch).length > 0) onSave(patch);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [kpFiles]); }, [kpFiles]);
@@ -31,12 +31,31 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0); const totalColSpan = FIXED_COLS + suppliersWithOffer.reduce((sum, s) => sum + getSubCols(supplierCurrencies[s.id] ?? 'KZT', isRestrictedView), 0);
// Высота row1 (шапка поставщика) переменная (теги валют, выбор поставщика, состояние Adata),
// а row2 прибит sticky сразу под ней. Измеряем реальную высоту и отдаём в CSS-переменную,
// иначе подзаголовки наезжают на первую строку таблицы.
const headerRow1Ref = React.useRef<HTMLTableRowElement>(null);
const [row1Height, setRow1Height] = React.useState(70);
React.useLayoutEffect(() => {
const el = headerRow1Ref.current;
if (!el) return;
const update = (): void => setRow1Height(Math.round(el.getBoundingClientRect().height));
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
return ( return (
<div className={styles.tableScrollWrapper}> <div className={styles.tableScrollWrapper}>
<table className={styles.ctTable}> <table
className={styles.ctTable}
style={{ '--header-row1-h': `${row1Height}px` } as React.CSSProperties}
>
<thead> <thead>
{/* Row 1: fixed headers (rowSpan=2) + supplier group headers (colSpan varies by currency) */} {/* Row 1: fixed headers (rowSpan=2) + supplier group headers (colSpan varies by currency) */}
<tr className={styles.headerRow1}> <tr className={styles.headerRow1} ref={headerRow1Ref}>
<th rowSpan={2} className={styles.colPosition}>Наименование</th> <th rowSpan={2} className={styles.colPosition}>Наименование</th>
<th rowSpan={2} className={styles.colUnit}>Ед. изм.</th> <th rowSpan={2} className={styles.colUnit}>Ед. изм.</th>
<th rowSpan={2} className={styles.colQty}>Кол-во</th> <th rowSpan={2} className={styles.colQty}>Кол-во</th>
@@ -48,7 +48,9 @@
border-bottom: 2px solid $accent-border; border-bottom: 2px solid $accent-border;
border-right: 1px solid $border; border-right: 1px solid $border;
position: sticky; position: sticky;
top: 70px; // approximate height of row1 — adjust if needed // Высота row1 (шапка поставщика) переменная — измеряется в ComparisonMatrix и прокидывается
// сюда через CSS-переменную. Хардкод приводил к наезду подзаголовков на первую строку.
top: var(--header-row1-h, 70px);
z-index: 1; z-index: 1;
white-space: nowrap; white-space: nowrap;
min-width: 90px; min-width: 90px;
@@ -265,3 +265,17 @@
.dialogError { .dialogError {
margin-top: 12px; 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 { ApprovalMemberStatus, FormStep } from "../types/domain";
import { CtService } from "../../services/CtService"; import { CtService } from "../../services/CtService";
import { CTFormData } from "../hooks/useCtForm"; import { CTFormData } from "../hooks/useCtForm";
import { useStep4Approval } from "./useStep4Approval"; import { useStep4Approval, IncoreStep } from "./useStep4Approval";
import styles from "./Step4Approval.module.scss"; import styles from "./Step4Approval.module.scss";
interface Props { interface Props {
@@ -23,6 +23,15 @@ const STATUS_LABELS: Record<ApprovalMemberStatus, string> = {
skipped: "Пропущено", skipped: "Пропущено",
}; };
const INCORE_STEP_LABELS: Record<IncoreStep, string> = {
report: "Формирование отчёта СТ…",
connecting: "Подключение к NCALayer…",
signing: "Ожидание подписи в NCALayer…",
registering: "Регистрация документа в SIGEX…",
finalizing: "Фиксация документа…",
building: "Формирование карточки с ЭЦП…",
};
function getStatusClass(status: ApprovalMemberStatus): string { function getStatusClass(status: ApprovalMemberStatus): string {
const map: Record<ApprovalMemberStatus, string> = { const map: Record<ApprovalMemberStatus, string> = {
pending: styles.statusPending, pending: styles.statusPending,
@@ -379,6 +388,42 @@ export const Step4Approval: React.FC<Props> = (props) => {
)} )}
</Dialog> </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> </div>
); );
}; };
@@ -1,8 +1,15 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain'; import { ApprovalMember, ApprovalMemberStatus, FormStep } from '../types/domain';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { SigexSignService, SigexSignStep } from '../../services/SigexSignService';
import { CTFormData } from '../hooks/useCtForm'; import { CTFormData } from '../hooks/useCtForm';
// Этапы выгрузки пакета в INCORE: формирование отчёта + этапы подписания SIGEX.
export type IncoreStep = 'report' | SigexSignStep;
// Подпись через SIGEX без состояния — один экземпляр на модуль.
const sigexSign = new SigexSignService();
export type ApprovalAction = 'approve' | 'reject'; export type ApprovalAction = 'approve' | 'reject';
export interface ActionDialogState { export interface ActionDialogState {
@@ -44,6 +51,8 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [notifyWarning, setNotifyWarning] = useState<string | null>(null); const [notifyWarning, setNotifyWarning] = useState<string | null>(null);
const [isSendingToIncore, setIsSendingToIncore] = useState(false); const [isSendingToIncore, setIsSendingToIncore] = useState(false);
const [incoreStep, setIncoreStep] = useState<IncoreStep | null>(null);
const [incoreError, setIncoreError] = useState<string | null>(null);
// ─── Add member ─── // ─── Add member ───
const [showAddRow, setShowAddRow] = useState(false); const [showAddRow, setShowAddRow] = useState(false);
@@ -60,16 +69,19 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => { const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
setChain((prev) => { const idx = chain.findIndex((m) => m.id === id);
const idx = prev.findIndex((m) => m.id === id); if (idx === -1) return;
if (idx === -1) return prev; const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
const next = [...prev]; if (swapIdx < 0 || swapIdx >= chain.length) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1; const next = [...chain];
if (swapIdx < 0 || swapIdx >= next.length) return prev; [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; const reordered = next.map((m, i) => ({ ...m, order: i + 1 }));
return next.map((m, i) => ({ ...m, order: i + 1 })); setChain(reordered);
}); // Пишем порядок в formData сразу, иначе переупорядочивание теряется при переходе на другой шаг
}, []); // (chain реинициализируется из formData.approvalChain при возврате). В SP порядок фиксируется
// на отправке на согласование (activateApprovalChain).
onSave({ approvalChain: reordered });
}, [chain, onSave]);
const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => { const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => {
setActionDialog({ setActionDialog({
@@ -321,9 +333,22 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
const handleSendToIncore = async (): Promise<void> => { const handleSendToIncore = async (): Promise<void> => {
if (!ct) return; if (!ct) return;
setIsSendingToIncore(true); setIsSendingToIncore(true);
setSubmitError(null); setIncoreError(null);
try { 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 ?? []; const kpFiles = formData.uploadedKpFiles ?? [];
for (const f of kpFiles) { for (const f of kpFiles) {
@@ -331,8 +356,13 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
await new Promise<void>((resolve) => setTimeout(resolve, 300)); await new Promise<void>((resolve) => setTimeout(resolve, 300));
} }
} catch (err) { } catch (err) {
setSubmitError((err as Error).message ?? 'Ошибка скачивания файлов'); // Пользователь закрыл окно NCALayer, не подписав — это не ошибка.
if ((err as { canceledByUser?: boolean }).canceledByUser) return;
setIncoreError(
(err as Error).message ?? 'Ошибка подписания и выгрузки пакета',
);
} finally { } finally {
setIncoreStep(null);
setIsSendingToIncore(false); setIsSendingToIncore(false);
} }
}; };
@@ -381,6 +411,9 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
dismissNotifyWarning: () => setNotifyWarning(null), dismissNotifyWarning: () => setNotifyWarning(null),
isSubmitting, isSubmitting,
isSendingToIncore, isSendingToIncore,
incoreStep,
incoreError,
dismissIncoreError: () => setIncoreError(null),
isOnApproval, isOnApproval,
showStatuses, showStatuses,
isInitiator, isInitiator,
@@ -1505,7 +1505,9 @@ export class CtService {
throw new Error(`Ошибка сохранения комментария ИИ: ${res.status}`); 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 token = await this._getApiToken();
const res = await fetch( const res = await fetch(
`${EXTERNAL_API}/Report/CT?ctid=${parseInt(ctId, 10)}&format=${format}`, `${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}`); 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 url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `CT_${ctId}.${format}`; a.download = fileName;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); 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' });
}
}