feat(ct): интеграция Adata и выбор рекомендованного поставщика по формуле 80/20

This commit is contained in:
Тимур Городецкий
2026-08-31 17:48:22 +05:00
parent 109053e3af
commit 00a0eb6ce7
15 changed files with 826 additions and 113 deletions
+20 -25
View File
@@ -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;
@@ -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
@@ -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>
)}
</>
);
}
@@ -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>
); );
@@ -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 => (
@@ -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>
)} )}
@@ -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;
@@ -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 };
}
@@ -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,
}; };
} }