Compare commits
3 Commits
5c9392fe49
...
f615ca3c95
| Author | SHA1 | Date | |
|---|---|---|---|
| f615ca3c95 | |||
| 158c007c15 | |||
| c1a5c6fde7 |
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>;
|
||||
}
|
||||
}
|
||||
+31
-4
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
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 { CTFormData } from '../hooks/useCtForm';
|
||||
import { FileUploadArea, UploadedFile } from './FileUploadArea';
|
||||
@@ -41,12 +41,39 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
isRestrictedView = false,
|
||||
}) => {
|
||||
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 [saveError, setSaveError] = useState<string | undefined>(undefined);
|
||||
|
||||
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> => {
|
||||
setIsSaving(true);
|
||||
setSaveError(undefined);
|
||||
@@ -98,11 +125,11 @@ export const Step2CommercialOffers: React.FC<Props> = ({
|
||||
|
||||
<section className={`${styles.section} ${styles.currencySection}`}>
|
||||
<h3 className={styles.sectionTitle}>Курсы валют</h3>
|
||||
<CurrencyRatesForm rates={currencyRates} onChange={setCurrencyRates} readOnly={isReadOnly} />
|
||||
<CurrencyRatesForm rates={currencyRates} onChange={handleCurrencyRatesChange} readOnly={isReadOnly} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={setNoOfferSuppliers} disabled={isReadOnly} />
|
||||
<SuppliersWithoutKp items={noOfferSuppliers} onChange={handleNoOfferChange} disabled={isReadOnly} />
|
||||
|
||||
{!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && (
|
||||
<UploadedKpList
|
||||
|
||||
+50
-39
@@ -12,6 +12,55 @@ export interface PersistStep2Args {
|
||||
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: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока,
|
||||
// пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave.
|
||||
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);
|
||||
}
|
||||
|
||||
const newSuppliersWithOffer: 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 { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates);
|
||||
|
||||
const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({
|
||||
id: s.localId,
|
||||
@@ -42,36 +83,6 @@ export async function persistStep2(args: PersistStep2Args): Promise<Partial<CTFo
|
||||
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.
|
||||
// saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here
|
||||
// before proceeding to Step 3.
|
||||
|
||||
+22
-3
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { CTFormData } from '../hooks/useCtForm';
|
||||
import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes';
|
||||
import { buildKpSuppliersAndOffers } from './step2Persist';
|
||||
|
||||
let localIdCounter = 0;
|
||||
|
||||
@@ -34,17 +35,35 @@ export function useKpProcessing(
|
||||
useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]);
|
||||
useEffect(() => () => { mountedRef.current = false; }, []);
|
||||
|
||||
// Сохраняем завершённые файлы в formData при изменении статуса
|
||||
// Сохраняем завершённые файлы в formData при изменении статуса: список загруженных КП, а также
|
||||
// поставщиков с офферами. Иначе поставщик, чей КП загрузили после первого сохранения Шага 2,
|
||||
// не доходит до formData.suppliers и не появляется колонкой в таблице сравнения на Шаге 3
|
||||
// (переход крошкой минует persistStep2). Валюта, выбранная для файла, тоже попадает сюда.
|
||||
useEffect(() => {
|
||||
const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined);
|
||||
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
|
||||
.filter(f => !currentUploaded.some(u => u.spId === f.spId))
|
||||
.map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName }));
|
||||
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
|
||||
}, [kpFiles]);
|
||||
|
||||
|
||||
+21
-2
@@ -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);
|
||||
|
||||
// Высота 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 (
|
||||
<div className={styles.tableScrollWrapper}>
|
||||
<table className={styles.ctTable}>
|
||||
<table
|
||||
className={styles.ctTable}
|
||||
style={{ '--header-row1-h': `${row1Height}px` } as React.CSSProperties}
|
||||
>
|
||||
<thead>
|
||||
{/* 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.colUnit}>Ед. изм.</th>
|
||||
<th rowSpan={2} className={styles.colQty}>Кол-во</th>
|
||||
|
||||
+3
-1
@@ -48,7 +48,9 @@
|
||||
border-bottom: 2px solid $accent-border;
|
||||
border-right: 1px solid $border;
|
||||
position: sticky;
|
||||
top: 70px; // approximate height of row1 — adjust if needed
|
||||
// Высота row1 (шапка поставщика) переменная — измеряется в ComparisonMatrix и прокидывается
|
||||
// сюда через CSS-переменную. Хардкод приводил к наезду подзаголовков на первую строку.
|
||||
top: var(--header-row1-h, 70px);
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
min-width: 90px;
|
||||
|
||||
+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);
|
||||
@@ -60,16 +69,19 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) {
|
||||
const replaceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => {
|
||||
setChain((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= next.length) return prev;
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
return next.map((m, i) => ({ ...m, order: i + 1 }));
|
||||
});
|
||||
}, []);
|
||||
const idx = chain.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return;
|
||||
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= chain.length) return;
|
||||
const next = [...chain];
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
const reordered = 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 => {
|
||||
setActionDialog({
|
||||
@@ -321,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) {
|
||||
@@ -331,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);
|
||||
}
|
||||
};
|
||||
@@ -381,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