feat(ct): интеграция Adata и выбор рекомендованного поставщика по формуле 80/20
This commit is contained in:
@@ -27,11 +27,7 @@
|
|||||||
"internalName": "status",
|
"internalName": "status",
|
||||||
"title": "Статус",
|
"title": "Статус",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": [
|
"choices": ["Черновик", "Согласовано", "На согласовании"]
|
||||||
"Черновик",
|
|
||||||
"Согласовано",
|
|
||||||
"На согласовании"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "winSupplier",
|
"internalName": "winSupplier",
|
||||||
@@ -105,10 +101,7 @@
|
|||||||
"internalName": "positionType",
|
"internalName": "positionType",
|
||||||
"title": "Тип",
|
"title": "Тип",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": [
|
"choices": ["ТМЦ", "Дополнительные затраты"]
|
||||||
"ТМЦ",
|
|
||||||
"Дополнительные затраты"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "deadline",
|
"internalName": "deadline",
|
||||||
@@ -226,12 +219,7 @@
|
|||||||
"internalName": "deliveryType",
|
"internalName": "deliveryType",
|
||||||
"title": "Условия поставки",
|
"title": "Условия поставки",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": [
|
"choices": ["DAP", "FOB", "DDP", "EXW"]
|
||||||
"DAP",
|
|
||||||
"FOB",
|
|
||||||
"DDP",
|
|
||||||
"EXW"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "paymentType",
|
"internalName": "paymentType",
|
||||||
@@ -247,10 +235,7 @@
|
|||||||
"internalName": "sertificates",
|
"internalName": "sertificates",
|
||||||
"title": "Сертификаты",
|
"title": "Сертификаты",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": [
|
"choices": ["Да", "Нет"]
|
||||||
"Да",
|
|
||||||
"Нет"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "comment",
|
"internalName": "comment",
|
||||||
@@ -361,6 +346,21 @@
|
|||||||
"internalName": "status",
|
"internalName": "status",
|
||||||
"title": "Статус",
|
"title": "Статус",
|
||||||
"type": "Text"
|
"type": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "bin",
|
||||||
|
"title": "БИН",
|
||||||
|
"type": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "adataData",
|
||||||
|
"title": "Данные Adata",
|
||||||
|
"type": "Note"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"internalName": "adataCheckedAt",
|
||||||
|
"title": "Дата проверки Adata",
|
||||||
|
"type": "DateTime"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -373,12 +373,7 @@
|
|||||||
"internalName": "code",
|
"internalName": "code",
|
||||||
"title": "Код валюты",
|
"title": "Код валюты",
|
||||||
"type": "Choice",
|
"type": "Choice",
|
||||||
"choices": [
|
"choices": ["USD", "RUB", "CNY", "EUR"]
|
||||||
"USD",
|
|
||||||
"RUB",
|
|
||||||
"CNY",
|
|
||||||
"EUR"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"internalName": "courseDate",
|
"internalName": "courseDate",
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
|
|
||||||
.root {
|
.root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
// Изолирует стекинг-контекст всего портала: без этого элементы с position:sticky/z-index
|
||||||
|
// (залипающие колонки таблиц, шапки и т.п.) визуально "пробиваются" через затемнение модалок
|
||||||
|
// (Dialog из ui-kit рендерится React-порталом в document.body, вне этого дерева).
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Панель «назад к реестру» над встроенной формой.
|
// Панель «назад к реестру» над встроенной формой.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface KpFile {
|
|||||||
spId?: number;
|
spId?: number;
|
||||||
supplierName?: string;
|
supplierName?: string;
|
||||||
supplierSpId?: number;
|
supplierSpId?: number;
|
||||||
|
supplierBIN?: string;
|
||||||
flowDone: number;
|
flowDone: number;
|
||||||
flowTotal: number;
|
flowTotal: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
+9
@@ -109,6 +109,15 @@ export function useKpProcessing(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// БИН поставщика (для Adata) — берём первый результат из позиций, где он вообще пришёл
|
||||||
|
const supplierBIN = collectedResults.find(r => r.flowResult.supplierBIN)?.flowResult.supplierBIN;
|
||||||
|
if (supplierBIN && resolvedSupplierSpId) {
|
||||||
|
patchFile(localId, { supplierBIN });
|
||||||
|
service.saveSupplierBIN(resolvedSupplierSpId, supplierBIN).catch(() => {
|
||||||
|
// сохранение БИН не критично для основного процесса — молча продолжаем
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-save criteria (one record per supplier — take first result that has any criteria field)
|
// Auto-save criteria (one record per supplier — take first result that has any criteria field)
|
||||||
const criteriaEntry = collectedResults.find(
|
const criteriaEntry = collectedResults.find(
|
||||||
r => r.flowResult.conditions || r.flowResult.deliveryType || r.flowResult.deadline || r.flowResult.sertificates
|
r => r.flowResult.conditions || r.flowResult.deliveryType || r.flowResult.deadline || r.flowResult.sertificates
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Icon, Spinner, Dialog, Button, Link } from '@tnsu/ui-kit';
|
||||||
|
import { AdataCompanyInfo, Supplier } from '../types/domain';
|
||||||
|
import { AdataSupplierState } from './useAdataCheck';
|
||||||
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
supplier: Supplier;
|
||||||
|
state?: AdataSupplierState;
|
||||||
|
onCheck: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso?: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateOnly(iso?: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const COUNTERPARTY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
company: 'Юридическое лицо',
|
||||||
|
enterprise: 'Индивидуальный предприниматель',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RISK_FLAGS: Array<{ key: 'company_problems' | 'financial_problems' | 'unreliable_zakup' | 'head_problems'; label: string }> = [
|
||||||
|
{ key: 'company_problems', label: 'Компания' },
|
||||||
|
{ key: 'financial_problems', label: 'Финансы' },
|
||||||
|
{ key: 'unreliable_zakup', label: 'Закупки' },
|
||||||
|
{ key: 'head_problems', label: 'Руководитель' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: React.ReactNode }): JSX.Element | null {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.adataInfoRow}>
|
||||||
|
<span className={styles.adataInfoLabel}>{label}</span>
|
||||||
|
<span className={styles.adataInfoValue}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRiskRows(info: AdataCompanyInfo): Array<{ label: string; value: string }> {
|
||||||
|
return [
|
||||||
|
...RISK_FLAGS.map(f => ({ label: f.label, value: info[f.key] ? 'Да' : 'Нет' })),
|
||||||
|
{ label: 'Ликвидирована', value: info.end_date ? 'Да' : 'Нет' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInfoRows(info: AdataCompanyInfo): Array<{ label: string; value?: React.ReactNode }> {
|
||||||
|
return [
|
||||||
|
{ label: 'Полное наименование', value: info.name_ru },
|
||||||
|
{ label: 'Тип контрагента', value: COUNTERPARTY_TYPE_LABELS[info.counterparty_type] ?? info.counterparty_type },
|
||||||
|
{ label: 'Форма собственности', value: info.type_of_ownership || info.legal_form },
|
||||||
|
{ label: 'Юридический адрес', value: info.legal_address },
|
||||||
|
{ label: 'Регион', value: info.kato_name },
|
||||||
|
{ label: 'Вид деятельности', value: info.oked },
|
||||||
|
{ label: 'Дата регистрации', value: formatDateOnly(info.date_registration) },
|
||||||
|
{ label: 'Дата перерегистрации', value: formatDateOnly(info.last_date_registration) },
|
||||||
|
{ label: 'Дата ликвидации', value: info.end_date ? formatDateOnly(info.end_date) : undefined },
|
||||||
|
{ label: 'Размер компании', value: info.krp },
|
||||||
|
{ label: 'Сотрудников', value: info.employee_count || undefined },
|
||||||
|
{ label: 'Руководитель', value: info.fullname_director },
|
||||||
|
{ label: 'Плательщик НДС', value: info.is_nds_payer ? 'Да' : 'Нет' },
|
||||||
|
{ label: 'Резидентство', value: info.is_non_resident ? `Нерезидент${info.non_resident_country ? ` (${info.non_resident_country})` : ''}` : 'Резидент' },
|
||||||
|
{ label: 'Орган регистрации', value: info.registration_department_name },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компактный индикатор проверки Adata в шапке колонки поставщика: точка меняет цвет
|
||||||
|
// по результату (серая — не проверено, красная — есть риски, зелёная — рисков нет),
|
||||||
|
// клик по проверенному индикатору открывает модалку с полной карточкой контрагента.
|
||||||
|
export function AdataIndicator({ supplier, state, onCheck }: Props): JSX.Element | null {
|
||||||
|
const [showDialog, setShowDialog] = React.useState(false);
|
||||||
|
if (!state?.bin) return null;
|
||||||
|
|
||||||
|
const info = state.data;
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
return <Spinner size="small" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
const isError = !!state.error;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[styles.adataCheckBtn, isError ? styles.adataCheckBtnError : styles.adataCheckBtnIdle].join(' ')}
|
||||||
|
onClick={onCheck}
|
||||||
|
title={isError ? `Ошибка Adata: ${state.error}. Нажмите, чтобы повторить` : `БИН: ${state.bin}`}
|
||||||
|
>
|
||||||
|
<Icon name={isError ? 'warning' : 'search'} size="sm" />
|
||||||
|
Данные с Adata
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRisk = info.company_problems || info.financial_problems || info.unreliable_zakup || info.head_problems || !!info.end_date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[styles.adataCheckBtn, hasRisk ? styles.adataCheckBtnDanger : styles.adataCheckBtnOk].join(' ')}
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
title="Подробнее"
|
||||||
|
>
|
||||||
|
<span className={[styles.adataDot, hasRisk ? styles.adataDotDanger : styles.adataDotOk].join(' ')} />
|
||||||
|
Данные с Adata
|
||||||
|
</button>
|
||||||
|
{showDialog && (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
title={info.short_name || supplier.name}
|
||||||
|
subText={`БИН: ${info.biin}`}
|
||||||
|
onDismiss={() => setShowDialog(false)}
|
||||||
|
width="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onCheck}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => setShowDialog(false)}>Закрыть</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.adataDialogBody}>
|
||||||
|
<div className={styles.adataInfoSectionTitle}>Риски</div>
|
||||||
|
<div className={styles.adataInfoList}>
|
||||||
|
{buildRiskRows(info).map(row => <InfoRow key={row.label} label={row.label} value={row.value} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.adataInfoList}>
|
||||||
|
{buildInfoRows(info).map(row => <InfoRow key={row.label} label={row.label} value={row.value} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.adataMeta}><Link href={info.source_link} target="_blank">Подробнее на adata.kz</Link></div>
|
||||||
|
{state.checkedAt && <div className={styles.adataCheckedAt}>Проверено: {formatDateTime(state.checkedAt)}</div>}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+50
-41
@@ -5,6 +5,8 @@ import { MatrixRowView } from './MatrixRowView';
|
|||||||
import { ExtraPositionRow } from './ExtraPositionRow';
|
import { ExtraPositionRow } from './ExtraPositionRow';
|
||||||
import { AddExtraRow } from './AddExtraRow';
|
import { AddExtraRow } from './AddExtraRow';
|
||||||
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
|
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
|
||||||
|
import { UseAdataCheckResult } from './useAdataCheck';
|
||||||
|
import { AdataIndicator } from './AdataIndicator';
|
||||||
import styles from './Step3ComparisonTable.module.scss';
|
import styles from './Step3ComparisonTable.module.scss';
|
||||||
|
|
||||||
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
||||||
@@ -13,14 +15,16 @@ interface Props {
|
|||||||
matrix: Step3Matrix;
|
matrix: Step3Matrix;
|
||||||
currencyRates: CurrencyRate[];
|
currencyRates: CurrencyRate[];
|
||||||
isRestrictedView: boolean;
|
isRestrictedView: boolean;
|
||||||
|
adata: UseAdataCheckResult;
|
||||||
|
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
|
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
|
||||||
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
|
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
|
||||||
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
||||||
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView }: Props): JSX.Element {
|
export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adata, recommendedId }: Props): JSX.Element {
|
||||||
const {
|
const {
|
||||||
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, recommendedSupplierId,
|
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
|
||||||
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
|
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
|
||||||
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
|
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
@@ -39,57 +43,63 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView }: Pr
|
|||||||
{suppliersWithOffer.map(s => {
|
{suppliersWithOffer.map(s => {
|
||||||
const currency = supplierCurrencies[s.id] ?? 'KZT';
|
const currency = supplierCurrencies[s.id] ?? 'KZT';
|
||||||
const subCols = getSubCols(currency, isRestrictedView);
|
const subCols = getSubCols(currency, isRestrictedView);
|
||||||
const isRec = s.id === recommendedSupplierId;
|
|
||||||
const isSel = s.id === selectedSupplierId;
|
const isSel = s.id === selectedSupplierId;
|
||||||
|
const isRec = s.id === recommendedId;
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={s.id}
|
key={s.id}
|
||||||
colSpan={subCols}
|
colSpan={subCols}
|
||||||
className={[
|
className={[
|
||||||
styles.supplierGroupHeader,
|
styles.supplierGroupHeader,
|
||||||
isRec ? styles.colRecommended : '',
|
|
||||||
isSel ? styles.colSelected : '',
|
isSel ? styles.colSelected : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
>
|
>
|
||||||
<div className={styles.supplierHeader}>
|
<div className={styles.supplierHeader}>
|
||||||
<span className={styles.supplierName}>{s.name}</span>
|
<div className={styles.supplierNameRow}>
|
||||||
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
<span className={styles.supplierName}>{s.name}</span>
|
||||||
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
|
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
||||||
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
</div>
|
||||||
{!isReadOnly ? (
|
<div className={styles.supplierMetaRow}>
|
||||||
<select
|
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
|
||||||
className={styles.statusInput}
|
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
|
||||||
value={supplierStatuses[s.id] ?? ''}
|
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
||||||
onChange={e => handleStatusChange(s.id, e.target.value)}
|
</div>
|
||||||
aria-label={`Статус поставщика ${s.name}`}
|
<div className={styles.supplierControlsRow}>
|
||||||
>
|
|
||||||
<option value="">Статус…</option>
|
|
||||||
{SUPPLIER_STATUS_OPTIONS.map(opt => (
|
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
supplierStatuses[s.id] ? <span className={styles.statusBadge}>{supplierStatuses[s.id]}</span> : undefined
|
|
||||||
)}
|
|
||||||
<div className={styles.supplierActions}>
|
|
||||||
{!isReadOnly ? (
|
{!isReadOnly ? (
|
||||||
<>
|
<select
|
||||||
<button
|
className={styles.statusInput}
|
||||||
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
value={supplierStatuses[s.id] ?? ''}
|
||||||
onClick={() => setSelectedSupplierId(s.id)}
|
onChange={e => handleStatusChange(s.id, e.target.value)}
|
||||||
aria-pressed={isSel}
|
aria-label={`Статус поставщика ${s.name}`}
|
||||||
>
|
>
|
||||||
{isSel ? 'Выбран' : 'Выбрать'}
|
<option value="">Статус…</option>
|
||||||
</button>
|
{SUPPLIER_STATUS_OPTIONS.map(opt => (
|
||||||
<button
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
className={styles.deleteSupplierBtn}
|
))}
|
||||||
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)}
|
</select>
|
||||||
title="Удалить потенциального поставщика"
|
|
||||||
>×</button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
isSel && <span className={styles.selectedBadge}>Выбран</span>
|
supplierStatuses[s.id] ? <span className={styles.statusBadge}>{supplierStatuses[s.id]}</span> : undefined
|
||||||
)}
|
)}
|
||||||
|
<div className={styles.supplierActions}>
|
||||||
|
{!isReadOnly ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={[styles.selectSupplierBtn, isSel ? styles.selectSupplierBtnActive : ''].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => setSelectedSupplierId(s.id)}
|
||||||
|
aria-pressed={isSel}
|
||||||
|
>
|
||||||
|
{isSel ? 'Выбран' : 'Выбрать'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.deleteSupplierBtn}
|
||||||
|
onClick={() => handleDeleteSupplier(s.id, s.isExtraSupplier)}
|
||||||
|
title="Удалить потенциального поставщика"
|
||||||
|
>×</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
isSel && <span className={styles.selectedBadge}>Выбран</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@@ -208,13 +218,12 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView }: Pr
|
|||||||
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
||||||
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
const isRec = s.id === recommendedSupplierId;
|
|
||||||
const isSel = s.id === selectedSupplierId;
|
const isSel = s.id === selectedSupplierId;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={s.id}>
|
<React.Fragment key={s.id}>
|
||||||
{isRestrictedView
|
{isRestrictedView
|
||||||
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
|
? <td colSpan={subCols} className={isSel ? styles.colSelected : undefined} />
|
||||||
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.grandTotalCell, isRec && !isSel ? styles.grandTotalRecommended : '', isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
|
: <><td colSpan={subCols - 1} className={isSel ? styles.colSelected : undefined} /><td className={[styles.grandTotalCell, isSel ? styles.colSelected : ''].filter(Boolean).join(' ')}><strong>{formatKzt(total)}</strong></td></>
|
||||||
}
|
}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-6
@@ -18,8 +18,8 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
handleAddCustomCriteria, handleRemoveCustomCriteria,
|
handleAddCustomCriteria, handleRemoveCustomCriteria,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
|
|
||||||
const fixedRows: Array<{ key: keyof CriteriaEntry; label: string }> = [
|
const fixedRows: Array<{ key: keyof CriteriaEntry; label: string; required?: boolean }> = [
|
||||||
{ key: 'paymentType', label: 'Условия оплаты' },
|
{ key: 'paymentType', label: 'Условия оплаты', required: true },
|
||||||
{ key: 'deliveryType', label: 'Условия поставки' },
|
{ key: 'deliveryType', label: 'Условия поставки' },
|
||||||
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
|
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
|
||||||
{ key: 'sertificates', label: 'Сертификаты' },
|
{ key: 'sertificates', label: 'Сертификаты' },
|
||||||
@@ -45,8 +45,13 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{fixedRows.map(row => (
|
{fixedRows.map(row => (
|
||||||
<tr key={row.key}>
|
<tr key={row.key}>
|
||||||
<td className={styles.criteriaLabel}>{row.label}</td>
|
<td className={styles.criteriaLabel}>
|
||||||
{suppliersWithOffer.map(s => (
|
{row.label}
|
||||||
|
{row.required && <span className={styles.criteriaRequiredMark}> *</span>}
|
||||||
|
</td>
|
||||||
|
{suppliersWithOffer.map(s => {
|
||||||
|
const isEmpty = !(criteriaMap[s.id]?.[row.key] ?? '').trim();
|
||||||
|
return (
|
||||||
<td key={s.id} className={styles.criteriaValue}>
|
<td key={s.id} className={styles.criteriaValue}>
|
||||||
{!isReadOnly ? (
|
{!isReadOnly ? (
|
||||||
row.key === 'deliveryType' ? (
|
row.key === 'deliveryType' ? (
|
||||||
@@ -68,7 +73,7 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
</select>
|
</select>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.criteriaInput}>
|
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={criteriaMap[s.id]?.[row.key] ?? ''}
|
value={criteriaMap[s.id]?.[row.key] ?? ''}
|
||||||
onChange={val => updateCriteriaValue(s.id, row.key, val)}
|
onChange={val => updateCriteriaValue(s.id, row.key, val)}
|
||||||
@@ -83,7 +88,8 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
|||||||
criteriaMap[s.id]?.[row.key] || '—'
|
criteriaMap[s.id]?.[row.key] || '—'
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{customCriteria.map(row => (
|
{customCriteria.map(row => (
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { TextInput } from '@tnsu/ui-kit';
|
import { TextInput, Icon } from '@tnsu/ui-kit';
|
||||||
import { CurrencyRate } from '../types/domain';
|
import { CurrencyRate } from '../types/domain';
|
||||||
import { PositionRow } from './matrixTypes';
|
import { PositionRow } from './matrixTypes';
|
||||||
import { Step3Matrix } from './useStep3Matrix';
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
@@ -115,7 +115,9 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
|
|||||||
<button className={styles.bundleRemoveBtn} onClick={() => removeBundleItem(s.id, pos.id, rowIdx)} title="Удалить строку">×</button>
|
<button className={styles.bundleRemoveBtn} onClick={() => removeBundleItem(s.id, pos.id, rowIdx)} title="Удалить строку">×</button>
|
||||||
)}
|
)}
|
||||||
{isLastItemRow && (
|
{isLastItemRow && (
|
||||||
<button className={styles.bundleAddBtn} onClick={() => addBundleItem(s.id, pos.id)} title="Добавить строку комплекта">+</button>
|
<button className={styles.bundleAddBtn} onClick={() => addBundleItem(s.id, pos.id)} title="Добавить строку комплекта">
|
||||||
|
<Icon name="plus" size="sm" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+167
-16
@@ -89,17 +89,12 @@
|
|||||||
padding: 8px 12px !important;
|
padding: 8px 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colRecommended {
|
|
||||||
background: $accent-bg !important;
|
|
||||||
border-top: 3px solid $accent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.colSelected {
|
.colSelected {
|
||||||
background: rgba($gold-500, 0.02) !important;
|
background: rgba($ok, 0.05) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.supplierGroupHeader.colSelected {
|
.supplierGroupHeader.colSelected {
|
||||||
border-top: 5px solid $accent !important;
|
border-top: 5px solid $ok !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.colSelectedFirst { }
|
.colSelectedFirst { }
|
||||||
@@ -140,7 +135,31 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierNameRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierMetaRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplierControlsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.supplierName {
|
.supplierName {
|
||||||
@@ -152,7 +171,7 @@
|
|||||||
.recommendedBadge {
|
.recommendedBadge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: $accent;
|
background: $ok;
|
||||||
color: $surface;
|
color: $surface;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -252,8 +271,11 @@
|
|||||||
&:hover { background: $accent-bg; }
|
&:hover { background: $accent-bg; }
|
||||||
|
|
||||||
&.selectSupplierBtnActive {
|
&.selectSupplierBtnActive {
|
||||||
background: $accent;
|
background: $ok;
|
||||||
|
border-color: $ok;
|
||||||
color: $surface;
|
color: $surface;
|
||||||
|
|
||||||
|
&:hover { background: $ok; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +311,7 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: $accent;
|
background: $ok;
|
||||||
color: $surface;
|
color: $surface;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -522,11 +544,6 @@
|
|||||||
border-left: 1px solid $accent-border;
|
border-left: 1px solid $accent-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grandTotalRecommended {
|
|
||||||
background: $accent-border !important;
|
|
||||||
color: $accent-dark !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Extra position badge ──────────────────────────────────────────────────
|
// ─── Extra position badge ──────────────────────────────────────────────────
|
||||||
|
|
||||||
.extraBadge {
|
.extraBadge {
|
||||||
@@ -619,6 +636,10 @@
|
|||||||
background: $accent-bg;
|
background: $accent-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.criteriaRequiredMark {
|
||||||
|
color: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
.criteriaInput {
|
.criteriaInput {
|
||||||
border-bottom: 1px dashed $border-strong;
|
border-bottom: 1px dashed $border-strong;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
@@ -644,6 +665,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.criteriaInputRequired {
|
||||||
|
border-bottom: 1px dashed $danger;
|
||||||
|
|
||||||
|
&:hover { border-bottom-color: $danger; }
|
||||||
|
}
|
||||||
|
|
||||||
.criteriaSelect {
|
.criteriaSelect {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -926,6 +953,130 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Adata indicator (в шапке колонки поставщика) + модалка ───────────────────
|
||||||
|
|
||||||
|
.adataDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataDotOk {
|
||||||
|
background: $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataDotDanger {
|
||||||
|
background: $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckBtnIdle {
|
||||||
|
background: $head-bg;
|
||||||
|
color: $muted;
|
||||||
|
border: 1px solid $border;
|
||||||
|
|
||||||
|
&:hover { background: $accent-bg; color: $accent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckBtnError {
|
||||||
|
background: $warn-bg;
|
||||||
|
color: $warn-fg;
|
||||||
|
border: 1px solid $warn-fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckBtnOk {
|
||||||
|
background: $ok-bg;
|
||||||
|
color: $ok;
|
||||||
|
border: 1px solid $ok;
|
||||||
|
|
||||||
|
&:hover { opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckBtnDanger {
|
||||||
|
background: $danger-bg;
|
||||||
|
color: $danger;
|
||||||
|
border: 1px solid $danger;
|
||||||
|
|
||||||
|
&:hover { opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataDialogBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataInfoSectionTitle {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataInfoList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataInfoRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid $border;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataInfoLabel {
|
||||||
|
flex: 0 0 44%;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataInfoValue {
|
||||||
|
flex: 1;
|
||||||
|
color: $text;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckedAt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckAllGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adataCheckAllLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $muted;
|
||||||
|
}
|
||||||
|
|
||||||
.approvalChainStatusBadge {
|
.approvalChainStatusBadge {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
+31
-1
@@ -11,6 +11,8 @@ import { AiCommentBlock } from './AiCommentBlock';
|
|||||||
import { useStep3Matrix } from './useStep3Matrix';
|
import { useStep3Matrix } from './useStep3Matrix';
|
||||||
import { ComparisonMatrix } from './ComparisonMatrix';
|
import { ComparisonMatrix } from './ComparisonMatrix';
|
||||||
import { CriteriaTable } from './CriteriaTable';
|
import { CriteriaTable } from './CriteriaTable';
|
||||||
|
import { useAdataCheck } from './useAdataCheck';
|
||||||
|
import { useRecommendedPick } from './useRecommendedPick';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
service: CtService;
|
service: CtService;
|
||||||
@@ -47,6 +49,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
||||||
} = matrix;
|
} = matrix;
|
||||||
|
|
||||||
|
// Проверка контрагентов в Adata вынесена в хук.
|
||||||
|
const adata = useAdataCheck(service, suppliersWithOffer);
|
||||||
|
|
||||||
|
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
||||||
|
const recPick = useRecommendedPick(service, matrix);
|
||||||
|
|
||||||
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
|
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
|
||||||
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
|
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
|
||||||
|
|
||||||
@@ -124,9 +132,31 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
|||||||
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно)
|
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<div className={styles.adataCheckAllGroup}>
|
||||||
|
<span className={styles.adataCheckAllLabel}>Данные с Adata</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void adata.checkAllSuppliers()}
|
||||||
|
disabled={adata.isCheckingAll || !suppliersWithOffer.some(s => adata.adataMap[s.id]?.bin)}
|
||||||
|
>
|
||||||
|
{adata.isCheckingAll ? <Spinner size="small" /> : <Icon name="search" size="sm" />}
|
||||||
|
Проверить всех
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void recPick.pickRecommendedSupplier()}
|
||||||
|
disabled={recPick.isPicking || !recPick.allPaymentTermsFilled}
|
||||||
|
title={!recPick.allPaymentTermsFilled ? 'Заполните условия оплаты у всех поставщиков' : undefined}
|
||||||
|
>
|
||||||
|
{recPick.isPicking ? <Spinner size="small" /> : <Icon name="check" size="sm" />}
|
||||||
|
Выбрать рекомендованного поставщика
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComparisonMatrix matrix={matrix} currencyRates={currencyRates} isRestrictedView={isRestrictedView} />
|
{recPick.pickError && <Alert variant="err">{recPick.pickError}</Alert>}
|
||||||
|
|
||||||
|
<ComparisonMatrix matrix={matrix} currencyRates={currencyRates} isRestrictedView={isRestrictedView} adata={adata} recommendedId={recPick.recommendedId} />
|
||||||
|
|
||||||
<CriteriaTable matrix={matrix} />
|
<CriteriaTable matrix={matrix} />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { AdataCompanyInfo, Supplier } from '../types/domain';
|
||||||
|
|
||||||
|
export interface AdataSupplierState {
|
||||||
|
bin?: string;
|
||||||
|
data?: AdataCompanyInfo;
|
||||||
|
checkedAt?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAdataCheckResult {
|
||||||
|
adataMap: Record<string, AdataSupplierState>; // keyed by supplier.id
|
||||||
|
checkSupplier: (supplierId: string) => Promise<void>;
|
||||||
|
checkAllSuppliers: () => Promise<void>;
|
||||||
|
isCheckingAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка контрагентов в Adata (Шаг 3): подтягивает БИН + кэш прошлой проверки из списка
|
||||||
|
// Поставщики при монтировании, по кнопке запускает живой запрос (Get+poll Check) и сохраняет
|
||||||
|
// результат обратно в SP, чтобы повторное открытие Шага 3 не тратило платный запрос.
|
||||||
|
export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAdataCheckResult {
|
||||||
|
const [adataMap, setAdataMap] = useState<Record<string, AdataSupplierState>>({});
|
||||||
|
const [isCheckingAll, setIsCheckingAll] = useState(false);
|
||||||
|
const suppliersRef = useRef(suppliers);
|
||||||
|
const adataMapRef = useRef(adataMap);
|
||||||
|
useEffect(() => { suppliersRef.current = suppliers; }, [suppliers]);
|
||||||
|
useEffect(() => { adataMapRef.current = adataMap; }, [adataMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const supplierSpIds = suppliers
|
||||||
|
.filter(s => s.hasOffer && s.spId !== undefined)
|
||||||
|
.map(s => s.spId as number);
|
||||||
|
if (supplierSpIds.length === 0) return;
|
||||||
|
service.loadSupplierAdataCache(supplierSpIds)
|
||||||
|
.then(rows => {
|
||||||
|
setAdataMap(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
rows.forEach(r => {
|
||||||
|
const sup = suppliersRef.current.find(s => s.spId === r.supplierSpId);
|
||||||
|
if (!sup) return;
|
||||||
|
next[sup.id] = {
|
||||||
|
...next[sup.id],
|
||||||
|
bin: r.bin,
|
||||||
|
data: r.adataData,
|
||||||
|
checkedAt: r.adataCheckedAt,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkSupplier = useCallback(async (supplierId: string): Promise<void> => {
|
||||||
|
const sup = suppliersRef.current.find(s => s.id === supplierId);
|
||||||
|
const bin = adataMapRef.current[supplierId]?.bin ?? sup?.bin;
|
||||||
|
if (!sup || !bin) {
|
||||||
|
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: true, error: undefined } }));
|
||||||
|
try {
|
||||||
|
const data = await service.fetchAdataInfo(bin);
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
setAdataMap(prev => ({ ...prev, [supplierId]: { bin, data, checkedAt, isLoading: false } }));
|
||||||
|
if (sup.spId !== undefined) {
|
||||||
|
service.saveAdataResult(sup.spId, data).catch(() => undefined);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false, error: (err as Error).message } }));
|
||||||
|
}
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
|
const checkAllSuppliers = useCallback(async (): Promise<void> => {
|
||||||
|
setIsCheckingAll(true);
|
||||||
|
const targets = suppliersRef.current.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin));
|
||||||
|
await Promise.all(targets.map(s => checkSupplier(s.id)));
|
||||||
|
setIsCheckingAll(false);
|
||||||
|
}, [checkSupplier]);
|
||||||
|
|
||||||
|
return { adataMap, checkSupplier, checkAllSuppliers, isCheckingAll };
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { CtService } from '../../services/CtService';
|
||||||
|
import { Step3Matrix } from './useStep3Matrix';
|
||||||
|
|
||||||
|
export interface UseRecommendedPickResult {
|
||||||
|
isPicking: boolean;
|
||||||
|
pickError?: string;
|
||||||
|
allPaymentTermsFilled: boolean;
|
||||||
|
recommendedId?: string;
|
||||||
|
pickRecommendedSupplier: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRICE_WEIGHT = 0.8;
|
||||||
|
const PAYMENT_WEIGHT = 0.2;
|
||||||
|
|
||||||
|
// ИИ не гарантированно возвращает имя поставщика байт-в-байт (кавычки/регистр/пробелы) —
|
||||||
|
// сравниваем в нормализованном виде, а не строгим ===.
|
||||||
|
function normalizeName(name: string): string {
|
||||||
|
return name.trim().toLowerCase().replace(/[«»"']/g, '').replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты (Шаг 3).
|
||||||
|
// Условия оплаты (свободный текст) переводятся в десятичный балл 0..1 запросом к Gemini (через
|
||||||
|
// прокси) — специально десятичный, а не целые 0/1/2: иначе грубые скачки шкалы оплаты при 20% веса
|
||||||
|
// на практике перевешивают 80% веса цены. Цена оценивается как % переплаты относительно самого
|
||||||
|
// дешёвого (не мин/макс "победитель забирает всё") — так небольшая разница в цене не убивает влияние
|
||||||
|
// условий оплаты. Победитель сразу становится выбранным.
|
||||||
|
export function useRecommendedPick(service: CtService, matrix: Step3Matrix): UseRecommendedPickResult {
|
||||||
|
const [isPicking, setIsPicking] = useState(false);
|
||||||
|
const [pickError, setPickError] = useState<string | undefined>(undefined);
|
||||||
|
const [recommendedId, setRecommendedId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const { suppliersWithOffer, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix;
|
||||||
|
|
||||||
|
const allPaymentTermsFilled = suppliersWithOffer.length > 0
|
||||||
|
&& suppliersWithOffer.every(s => (criteriaMap[s.id]?.paymentType ?? '').trim().length > 0);
|
||||||
|
|
||||||
|
const pickRecommendedSupplier = useCallback(async (): Promise<void> => {
|
||||||
|
if (!allPaymentTermsFilled) return;
|
||||||
|
setIsPicking(true);
|
||||||
|
setPickError(undefined);
|
||||||
|
try {
|
||||||
|
const totals = suppliersWithOffer
|
||||||
|
.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
paymentType: criteriaMap[s.id]?.paymentType ?? '',
|
||||||
|
total: allPositions.reduce((sum, pos) => {
|
||||||
|
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
||||||
|
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
||||||
|
}, 0),
|
||||||
|
}))
|
||||||
|
.filter(t => t.total > 0);
|
||||||
|
|
||||||
|
if (totals.length === 0) {
|
||||||
|
setPickError('Нет сохранённых цен — нечего сравнивать');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = await service.scorePaymentTerms(totals.map(t => ({ name: t.name, paymentType: t.paymentType })));
|
||||||
|
|
||||||
|
const minTotal = Math.min(...totals.map(t => t.total));
|
||||||
|
|
||||||
|
const ranked = totals.map((t, i) => {
|
||||||
|
// % переплаты относительно самого дешёвого, зажато снизу нулём (на случай цены >2x минимума).
|
||||||
|
const priceScore = Math.max(0, 1 - (t.total - minTotal) / minTotal);
|
||||||
|
const matched = scores.find(s => normalizeName(s.supplier) === normalizeName(t.name));
|
||||||
|
// Если имя не совпало (ИИ переформулировал) — подстраховка по порядку, т.к. отправляли
|
||||||
|
// нумерованным списком и просили сохранить соответствие.
|
||||||
|
const geminiScore = matched?.score ?? scores[i]?.score ?? 0;
|
||||||
|
// Балл уже приходит в шкале 0..1 — на всякий случай подрезаем, если ИИ вышел за границы.
|
||||||
|
const paymentScore = Math.min(1, Math.max(0, geminiScore));
|
||||||
|
return { id: t.id, combined: PRICE_WEIGHT * priceScore + PAYMENT_WEIGHT * paymentScore };
|
||||||
|
});
|
||||||
|
|
||||||
|
const winner = ranked.reduce((best, r) => (r.combined > best.combined ? r : best));
|
||||||
|
setSelectedSupplierId(winner.id);
|
||||||
|
setRecommendedId(winner.id);
|
||||||
|
} catch (err) {
|
||||||
|
setPickError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setIsPicking(false);
|
||||||
|
}
|
||||||
|
}, [allPaymentTermsFilled, suppliersWithOffer, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
|
||||||
|
|
||||||
|
return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier };
|
||||||
|
}
|
||||||
@@ -369,27 +369,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
return map;
|
return map;
|
||||||
}, [computedOffers, allPositions, suppliersWithOffer]);
|
}, [computedOffers, allPositions, suppliersWithOffer]);
|
||||||
|
|
||||||
const recommendedSupplierId = useMemo((): string | null => {
|
|
||||||
if (suppliersWithOffer.length === 0) return null;
|
|
||||||
const totals = suppliersWithOffer.map(s => ({
|
|
||||||
supplierId: s.id,
|
|
||||||
total: allPositions.reduce((sum, pos) => {
|
|
||||||
const posOffers = computedOffers.filter(o => o.supplierId === s.id && o.positionId === pos.id);
|
|
||||||
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
|
||||||
}, 0),
|
|
||||||
}));
|
|
||||||
const nonZero = totals.filter(t => t.total > 0);
|
|
||||||
if (nonZero.length === 0) return null;
|
|
||||||
const minTotal = Math.min(...nonZero.map(t => t.total));
|
|
||||||
return nonZero.find(t => t.total === minTotal)?.supplierId ?? null;
|
|
||||||
}, [computedOffers, allPositions, suppliersWithOffer]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (selectedSupplierId === undefined && recommendedSupplierId !== null) {
|
|
||||||
setSelectedSupplierId(recommendedSupplierId);
|
|
||||||
}
|
|
||||||
}, [recommendedSupplierId, selectedSupplierId]);
|
|
||||||
|
|
||||||
const updateEdit = useCallback(
|
const updateEdit = useCallback(
|
||||||
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
|
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
|
||||||
if (isReadOnly) return;
|
if (isReadOnly) return;
|
||||||
@@ -830,7 +809,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
|||||||
supplierCurrencies,
|
supplierCurrencies,
|
||||||
computedOffers,
|
computedOffers,
|
||||||
minTotalByPosition,
|
minTotalByPosition,
|
||||||
recommendedSupplierId,
|
|
||||||
// handlers
|
// handlers
|
||||||
updateEdit,
|
updateEdit,
|
||||||
updatePriceKzt,
|
updatePriceKzt,
|
||||||
|
|||||||
@@ -54,9 +54,40 @@ export interface Supplier {
|
|||||||
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
|
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
|
||||||
name: string;
|
name: string;
|
||||||
inn?: string; // UI only — not stored in SP
|
inn?: string; // UI only — not stored in SP
|
||||||
|
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
|
||||||
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
|
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
|
||||||
status?: string;
|
status?: string;
|
||||||
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
currency?: string; // currency code for KP (KZT by default); stored here so Step 3 can show correct columns even if offers are empty
|
||||||
|
adataData?: AdataCompanyInfo; // кэш последней проверки Adata — SP-поле 'adataData' (JSON)
|
||||||
|
adataCheckedAt?: string; // дата последней проверки Adata — SP-поле 'adataCheckedAt'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ответ Adata (через прокси api.tnsu.kz/Proxy/AData) по БИН/ИИН контрагента.
|
||||||
|
export interface AdataCompanyInfo {
|
||||||
|
name_ru: string;
|
||||||
|
short_name: string;
|
||||||
|
biin: string;
|
||||||
|
legal_address: string;
|
||||||
|
oked: string;
|
||||||
|
kato_name: string; // регион (район/город)
|
||||||
|
fullname_director: string;
|
||||||
|
date_registration: string;
|
||||||
|
last_date_registration: string; // дата последней перерегистрации
|
||||||
|
end_date: string | null; // не null → компания ликвидирована
|
||||||
|
krp: string; // категория по размеру, напр. "Малые предприятия (<= 5)"
|
||||||
|
company_problems: boolean;
|
||||||
|
financial_problems: boolean;
|
||||||
|
unreliable_zakup: boolean;
|
||||||
|
head_problems: boolean;
|
||||||
|
is_nds_payer: boolean;
|
||||||
|
is_non_resident: boolean;
|
||||||
|
non_resident_country: string | null;
|
||||||
|
counterparty_type: string; // company | enterprise
|
||||||
|
type_of_ownership: string; // форма собственности
|
||||||
|
legal_form: string;
|
||||||
|
employee_count: number;
|
||||||
|
registration_department_name: string; // орган регистрации
|
||||||
|
source_link: string; // ссылка на полную карточку на adata.kz
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyntexFields {
|
export interface SyntexFields {
|
||||||
@@ -77,6 +108,7 @@ export interface FlowResult {
|
|||||||
price?: number;
|
price?: number;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
supplier?: string;
|
supplier?: string;
|
||||||
|
supplierBIN?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrencyRate {
|
export interface CurrencyRate {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ApprovalMemberStatus,
|
ApprovalMemberStatus,
|
||||||
SyntexFields,
|
SyntexFields,
|
||||||
FlowResult,
|
FlowResult,
|
||||||
|
AdataCompanyInfo,
|
||||||
} from "../components/types/domain";
|
} from "../components/types/domain";
|
||||||
|
|
||||||
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
// Round monetary values to 2 decimals before persisting to SharePoint (drops float tails).
|
||||||
@@ -21,6 +22,8 @@ function round2(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXTERNAL_API = "https://api.tnsu.kz";
|
const EXTERNAL_API = "https://api.tnsu.kz";
|
||||||
|
const ADATA_POLL_INTERVAL_MS = 5000;
|
||||||
|
const ADATA_MAX_POLL_RETRIES = 18; // 18 * 5s = 90s — макс. время по документации Adata
|
||||||
const FLOW_URL =
|
const FLOW_URL =
|
||||||
"https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com/powerautomate/automations/direct/workflows/60ec47fdba7c453ea72bfed57bcfa3a2/triggers/manual/paths/invoke?api-version=1";
|
"https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com/powerautomate/automations/direct/workflows/60ec47fdba7c453ea72bfed57bcfa3a2/triggers/manual/paths/invoke?api-version=1";
|
||||||
const AI_COMMENT_FLOW_URL =
|
const AI_COMMENT_FLOW_URL =
|
||||||
@@ -1162,6 +1165,173 @@ export class CtService {
|
|||||||
return Number(data.Id);
|
return Number(data.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Записывает БИН поставщика в уже существующую запись списка Поставщики (для запросов в Adata).
|
||||||
|
async saveSupplierBIN(supplierSpId: number, bin: string): Promise<void> {
|
||||||
|
const res = await this.http.post(
|
||||||
|
this._liById(LISTS.SUPPLIERS, supplierSpId),
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;odata=nometadata",
|
||||||
|
"IF-MATCH": "*",
|
||||||
|
"X-HTTP-Method": "MERGE",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ bin }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`Ошибка сохранения БИН поставщика: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка контрагента в Adata через внутренний прокси (long polling: Get → token → Check → data/wait).
|
||||||
|
async fetchAdataInfo(bin: string): Promise<AdataCompanyInfo> {
|
||||||
|
const apiToken = await this._getApiToken();
|
||||||
|
const getRes = await fetch(
|
||||||
|
`${EXTERNAL_API}/Proxy/AData/Get?iinBin=${encodeURIComponent(bin)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${apiToken}` } },
|
||||||
|
);
|
||||||
|
if (!getRes.ok)
|
||||||
|
throw new Error(`Ошибка запроса в Adata: ${getRes.status}`);
|
||||||
|
const getData = await getRes.json();
|
||||||
|
const requestToken = getData.token as string | undefined;
|
||||||
|
if (!requestToken) throw new Error("Adata не вернул токен запроса");
|
||||||
|
return this._pollAdataCheck(requestToken, apiToken, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _pollAdataCheck(
|
||||||
|
requestToken: string,
|
||||||
|
apiToken: string,
|
||||||
|
retries: number,
|
||||||
|
): Promise<AdataCompanyInfo> {
|
||||||
|
if (retries >= ADATA_MAX_POLL_RETRIES)
|
||||||
|
throw new Error("Adata не ответила за отведённое время");
|
||||||
|
const res = await fetch(
|
||||||
|
`${EXTERNAL_API}/Proxy/AData/Check?token=${encodeURIComponent(requestToken)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${apiToken}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`Ошибка проверки в Adata: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.body?.message === "wait") {
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
setTimeout(resolve, ADATA_POLL_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
return this._pollAdataCheck(requestToken, apiToken, retries + 1);
|
||||||
|
}
|
||||||
|
if (!data.body?.success || !data.body?.data)
|
||||||
|
throw new Error("Adata не смогла обработать запрос");
|
||||||
|
return data.body.data as AdataCompanyInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняет кэш результата проверки Adata в запись поставщика (для повторного открытия Шага 3 без нового платного запроса).
|
||||||
|
async saveAdataResult(
|
||||||
|
supplierSpId: number,
|
||||||
|
data: AdataCompanyInfo,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await this.http.post(
|
||||||
|
this._liById(LISTS.SUPPLIERS, supplierSpId),
|
||||||
|
SPHttpClient.configurations.v1,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json;odata=nometadata",
|
||||||
|
"IF-MATCH": "*",
|
||||||
|
"X-HTTP-Method": "MERGE",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
adataData: JSON.stringify(data),
|
||||||
|
adataCheckedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`Ошибка сохранения результата Adata: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подтягивает БИН + кэш проверки Adata по списку поставщиков (единый источник правды — список Поставщики).
|
||||||
|
async loadSupplierAdataCache(supplierSpIds: number[]): Promise<
|
||||||
|
Array<{
|
||||||
|
supplierSpId: number;
|
||||||
|
bin?: string;
|
||||||
|
adataData?: AdataCompanyInfo;
|
||||||
|
adataCheckedAt?: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (supplierSpIds.length === 0) return [];
|
||||||
|
const filter = supplierSpIds.map((id) => `Id eq ${id}`).join(" or ");
|
||||||
|
const url = `${this._li(LISTS.SUPPLIERS)}?$select=Id,bin,adataData,adataCheckedAt&$filter=${filter}`;
|
||||||
|
const res = await this.http.get(url, SPHttpClient.configurations.v1);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.value ?? []).map((item: Record<string, unknown>) => {
|
||||||
|
let adataData: AdataCompanyInfo | undefined;
|
||||||
|
try {
|
||||||
|
adataData = item.adataData
|
||||||
|
? (JSON.parse(String(item.adataData)) as AdataCompanyInfo)
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
adataData = undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
supplierSpId: Number(item.Id),
|
||||||
|
bin: item.bin ? String(item.bin) : undefined,
|
||||||
|
adataData,
|
||||||
|
adataCheckedAt: item.adataCheckedAt
|
||||||
|
? String(item.adataCheckedAt)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оценка условий оплаты поставщиков через ИИ (Gemini, прокси api.tnsu.kz) для формулы
|
||||||
|
// рекомендованного поставщика: 0.0 (хуже для нас, больше предоплата) .. 1.0 (лучше, больше постоплата).
|
||||||
|
// Шкала десятичная (не целые 0/1/2!), чтобы совпадать по гранулярности с непрерывным баллом цены —
|
||||||
|
// иначе грубые скачки (0/0.5/1) при весе 20% на практике перевешивают 80% веса цены.
|
||||||
|
async scorePaymentTerms(
|
||||||
|
suppliers: Array<{ name: string; paymentType: string }>,
|
||||||
|
): Promise<Array<{ supplier: string; score: number }>> {
|
||||||
|
const apiToken = await this._getApiToken();
|
||||||
|
const lines = suppliers
|
||||||
|
.map((s, i) => `${i + 1}. ${s.name} — ${s.paymentType}`)
|
||||||
|
.join("\n");
|
||||||
|
const prompt =
|
||||||
|
"Ты анализируешь условия оплаты поставщиков. Оцени каждого поставщика оценкой от 0.0 до 1.0 " +
|
||||||
|
"(десятичное число с точностью до сотых, НЕ только 0/0.5/1 — используй промежуточные значения " +
|
||||||
|
"вроде 0.3 или 0.75, отражая нюансы между похожими предложениями). Чем лучше условия для нас " +
|
||||||
|
"(чем больше постоплата, чем меньше предоплата, чем длиннее отсрочка), тем выше балл, ближе к 1.0. " +
|
||||||
|
`Поставщики и их условия оплаты:\n${lines}`;
|
||||||
|
const res = await fetch(`${EXTERNAL_API}/Proxy/Gemini`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
responseSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
supplier: { type: "string" },
|
||||||
|
score: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["supplier", "score"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["data"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Ошибка запроса к Gemini: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.data ?? []) as Array<{ supplier: string; score: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
async saveManualSupplierWithKp(
|
async saveManualSupplierWithKp(
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<{ spId: number; offerId: number }> {
|
): Promise<{ spId: number; offerId: number }> {
|
||||||
@@ -1418,6 +1588,10 @@ export class CtService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
supplier:
|
supplier:
|
||||||
parsed.supplier !== undefined ? String(parsed.supplier) : undefined,
|
parsed.supplier !== undefined ? String(parsed.supplier) : undefined,
|
||||||
|
supplierBIN:
|
||||||
|
parsed.supplierBIN !== undefined
|
||||||
|
? String(parsed.supplierBIN)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user