feat(ct): интеграция Adata и выбор рекомендованного поставщика по формуле 80/20
This commit is contained in:
@@ -27,11 +27,7 @@
|
||||
"internalName": "status",
|
||||
"title": "Статус",
|
||||
"type": "Choice",
|
||||
"choices": [
|
||||
"Черновик",
|
||||
"Согласовано",
|
||||
"На согласовании"
|
||||
]
|
||||
"choices": ["Черновик", "Согласовано", "На согласовании"]
|
||||
},
|
||||
{
|
||||
"internalName": "winSupplier",
|
||||
@@ -105,10 +101,7 @@
|
||||
"internalName": "positionType",
|
||||
"title": "Тип",
|
||||
"type": "Choice",
|
||||
"choices": [
|
||||
"ТМЦ",
|
||||
"Дополнительные затраты"
|
||||
]
|
||||
"choices": ["ТМЦ", "Дополнительные затраты"]
|
||||
},
|
||||
{
|
||||
"internalName": "deadline",
|
||||
@@ -226,12 +219,7 @@
|
||||
"internalName": "deliveryType",
|
||||
"title": "Условия поставки",
|
||||
"type": "Choice",
|
||||
"choices": [
|
||||
"DAP",
|
||||
"FOB",
|
||||
"DDP",
|
||||
"EXW"
|
||||
]
|
||||
"choices": ["DAP", "FOB", "DDP", "EXW"]
|
||||
},
|
||||
{
|
||||
"internalName": "paymentType",
|
||||
@@ -247,10 +235,7 @@
|
||||
"internalName": "sertificates",
|
||||
"title": "Сертификаты",
|
||||
"type": "Choice",
|
||||
"choices": [
|
||||
"Да",
|
||||
"Нет"
|
||||
]
|
||||
"choices": ["Да", "Нет"]
|
||||
},
|
||||
{
|
||||
"internalName": "comment",
|
||||
@@ -361,6 +346,21 @@
|
||||
"internalName": "status",
|
||||
"title": "Статус",
|
||||
"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",
|
||||
"title": "Код валюты",
|
||||
"type": "Choice",
|
||||
"choices": [
|
||||
"USD",
|
||||
"RUB",
|
||||
"CNY",
|
||||
"EUR"
|
||||
]
|
||||
"choices": ["USD", "RUB", "CNY", "EUR"]
|
||||
},
|
||||
{
|
||||
"internalName": "courseDate",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
// Изолирует стекинг-контекст всего портала: без этого элементы с position:sticky/z-index
|
||||
// (залипающие колонки таблиц, шапки и т.п.) визуально "пробиваются" через затемнение модалок
|
||||
// (Dialog из ui-kit рендерится React-порталом в document.body, вне этого дерева).
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
// Панель «назад к реестру» над встроенной формой.
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface KpFile {
|
||||
spId?: number;
|
||||
supplierName?: string;
|
||||
supplierSpId?: number;
|
||||
supplierBIN?: string;
|
||||
flowDone: number;
|
||||
flowTotal: number;
|
||||
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)
|
||||
const criteriaEntry = collectedResults.find(
|
||||
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 { AddExtraRow } from './AddExtraRow';
|
||||
import { SUPPLIER_STATUS_OPTIONS, getSubCols, formatKzt } from './matrixHelpers';
|
||||
import { UseAdataCheckResult } from './useAdataCheck';
|
||||
import { AdataIndicator } from './AdataIndicator';
|
||||
import styles from './Step3ComparisonTable.module.scss';
|
||||
|
||||
const FIXED_COLS = 3; // Наименование / Ед. изм. / Кол-во
|
||||
@@ -13,14 +15,16 @@ interface Props {
|
||||
matrix: Step3Matrix;
|
||||
currencyRates: CurrencyRate[];
|
||||
isRestrictedView: boolean;
|
||||
adata: UseAdataCheckResult;
|
||||
recommendedId?: string; // победитель формулы 80% цена / 20% условия оплаты (useRecommendedPick)
|
||||
}
|
||||
|
||||
// Таблица сравнения целиком: шапка (фикс-колонки + заголовки поставщиков) + строки позиций
|
||||
// (MatrixRowView/ExtraPositionRow) + итоговые строки + строка добавления (AddExtraRow).
|
||||
// Перенесено 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 {
|
||||
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId, recommendedSupplierId,
|
||||
isReadOnly, suppliersWithOffer, supplierCurrencies, selectedSupplierId,
|
||||
supplierStatuses, nameColWidth, regularPositions, extraPositionsRows, allPositions, computedOffers,
|
||||
setSelectedSupplierId, handleDeleteSupplier, handleStatusChange, handleNameColResizeMouseDown,
|
||||
} = matrix;
|
||||
@@ -39,57 +43,63 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView }: Pr
|
||||
{suppliersWithOffer.map(s => {
|
||||
const currency = supplierCurrencies[s.id] ?? 'KZT';
|
||||
const subCols = getSubCols(currency, isRestrictedView);
|
||||
const isRec = s.id === recommendedSupplierId;
|
||||
const isSel = s.id === selectedSupplierId;
|
||||
const isRec = s.id === recommendedId;
|
||||
return (
|
||||
<th
|
||||
key={s.id}
|
||||
colSpan={subCols}
|
||||
className={[
|
||||
styles.supplierGroupHeader,
|
||||
isRec ? styles.colRecommended : '',
|
||||
isSel ? styles.colSelected : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className={styles.supplierHeader}>
|
||||
<span className={styles.supplierName}>{s.name}</span>
|
||||
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
||||
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
|
||||
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
||||
{!isReadOnly ? (
|
||||
<select
|
||||
className={styles.statusInput}
|
||||
value={supplierStatuses[s.id] ?? ''}
|
||||
onChange={e => handleStatusChange(s.id, e.target.value)}
|
||||
aria-label={`Статус поставщика ${s.name}`}
|
||||
>
|
||||
<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}>
|
||||
<div className={styles.supplierNameRow}>
|
||||
<span className={styles.supplierName}>{s.name}</span>
|
||||
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
||||
</div>
|
||||
<div className={styles.supplierMetaRow}>
|
||||
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
|
||||
{s.isNoOffer && <span className={styles.noOfferTag}>Без КП</span>}
|
||||
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
||||
</div>
|
||||
<div className={styles.supplierControlsRow}>
|
||||
{!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>
|
||||
</>
|
||||
<select
|
||||
className={styles.statusInput}
|
||||
value={supplierStatuses[s.id] ?? ''}
|
||||
onChange={e => handleStatusChange(s.id, e.target.value)}
|
||||
aria-label={`Статус поставщика ${s.name}`}
|
||||
>
|
||||
<option value="">Статус…</option>
|
||||
{SUPPLIER_STATUS_OPTIONS.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
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>
|
||||
</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);
|
||||
return sum + posOffers.reduce((s2, o) => s2 + (o.totalWithExtraKzt ?? 0), 0);
|
||||
}, 0);
|
||||
const isRec = s.id === recommendedSupplierId;
|
||||
const isSel = s.id === selectedSupplierId;
|
||||
return (
|
||||
<React.Fragment key={s.id}>
|
||||
{isRestrictedView
|
||||
? <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>
|
||||
);
|
||||
|
||||
+12
-6
@@ -18,8 +18,8 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
handleAddCustomCriteria, handleRemoveCustomCriteria,
|
||||
} = matrix;
|
||||
|
||||
const fixedRows: Array<{ key: keyof CriteriaEntry; label: string }> = [
|
||||
{ key: 'paymentType', label: 'Условия оплаты' },
|
||||
const fixedRows: Array<{ key: keyof CriteriaEntry; label: string; required?: boolean }> = [
|
||||
{ key: 'paymentType', label: 'Условия оплаты', required: true },
|
||||
{ key: 'deliveryType', label: 'Условия поставки' },
|
||||
{ key: 'deadline', label: 'Сроки изготовления и доставки' },
|
||||
{ key: 'sertificates', label: 'Сертификаты' },
|
||||
@@ -45,8 +45,13 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
<tbody>
|
||||
{fixedRows.map(row => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.criteriaLabel}>{row.label}</td>
|
||||
{suppliersWithOffer.map(s => (
|
||||
<td className={styles.criteriaLabel}>
|
||||
{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}>
|
||||
{!isReadOnly ? (
|
||||
row.key === 'deliveryType' ? (
|
||||
@@ -68,7 +73,7 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
</select>
|
||||
)
|
||||
) : (
|
||||
<div className={styles.criteriaInput}>
|
||||
<div className={[styles.criteriaInput, row.required && isEmpty ? styles.criteriaInputRequired : ''].filter(Boolean).join(' ')}>
|
||||
<TextArea
|
||||
value={criteriaMap[s.id]?.[row.key] ?? ''}
|
||||
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] || '—'
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{customCriteria.map(row => (
|
||||
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { TextInput } from '@tnsu/ui-kit';
|
||||
import { TextInput, Icon } from '@tnsu/ui-kit';
|
||||
import { CurrencyRate } from '../types/domain';
|
||||
import { PositionRow } from './matrixTypes';
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
+167
-16
@@ -89,17 +89,12 @@
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.colRecommended {
|
||||
background: $accent-bg !important;
|
||||
border-top: 3px solid $accent !important;
|
||||
}
|
||||
|
||||
.colSelected {
|
||||
background: rgba($gold-500, 0.02) !important;
|
||||
background: rgba($ok, 0.05) !important;
|
||||
}
|
||||
|
||||
.supplierGroupHeader.colSelected {
|
||||
border-top: 5px solid $accent !important;
|
||||
border-top: 5px solid $ok !important;
|
||||
}
|
||||
|
||||
.colSelectedFirst { }
|
||||
@@ -140,7 +135,31 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
@@ -152,7 +171,7 @@
|
||||
.recommendedBadge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: $accent;
|
||||
background: $ok;
|
||||
color: $surface;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
@@ -252,8 +271,11 @@
|
||||
&:hover { background: $accent-bg; }
|
||||
|
||||
&.selectSupplierBtnActive {
|
||||
background: $accent;
|
||||
background: $ok;
|
||||
border-color: $ok;
|
||||
color: $surface;
|
||||
|
||||
&:hover { background: $ok; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +311,7 @@
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 3px;
|
||||
background: $accent;
|
||||
background: $ok;
|
||||
color: $surface;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -522,11 +544,6 @@
|
||||
border-left: 1px solid $accent-border;
|
||||
}
|
||||
|
||||
.grandTotalRecommended {
|
||||
background: $accent-border !important;
|
||||
color: $accent-dark !important;
|
||||
}
|
||||
|
||||
// ─── Extra position badge ──────────────────────────────────────────────────
|
||||
|
||||
.extraBadge {
|
||||
@@ -619,6 +636,10 @@
|
||||
background: $accent-bg;
|
||||
}
|
||||
|
||||
.criteriaRequiredMark {
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
.criteriaInput {
|
||||
border-bottom: 1px dashed $border-strong;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
@@ -644,6 +665,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.criteriaInputRequired {
|
||||
border-bottom: 1px dashed $danger;
|
||||
|
||||
&:hover { border-bottom-color: $danger; }
|
||||
}
|
||||
|
||||
.criteriaSelect {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
@@ -926,6 +953,130 @@
|
||||
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 {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
|
||||
+31
-1
@@ -11,6 +11,8 @@ import { AiCommentBlock } from './AiCommentBlock';
|
||||
import { useStep3Matrix } from './useStep3Matrix';
|
||||
import { ComparisonMatrix } from './ComparisonMatrix';
|
||||
import { CriteriaTable } from './CriteriaTable';
|
||||
import { useAdataCheck } from './useAdataCheck';
|
||||
import { useRecommendedPick } from './useRecommendedPick';
|
||||
|
||||
interface Props {
|
||||
service: CtService;
|
||||
@@ -47,6 +49,12 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
||||
} = matrix;
|
||||
|
||||
// Проверка контрагентов в Adata вынесена в хук.
|
||||
const adata = useAdataCheck(service, suppliersWithOffer);
|
||||
|
||||
// Выбор рекомендованного поставщика по формуле 80% цена / 20% условия оплаты вынесен в хук.
|
||||
const recPick = useRecommendedPick(service, matrix);
|
||||
|
||||
// Согласование (инлайн-цепочка Teams-ссылки) вынесено в хук.
|
||||
const approval = useStep3Approval({ service, formData, userDisplayName, onSave, onStepComplete });
|
||||
|
||||
@@ -124,9 +132,31 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
кол-во и цена показаны в ед. изм. заявки; «*» — пересчёт невозможен (ред. недоступно)
|
||||
</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>
|
||||
|
||||
<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} />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}, [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(
|
||||
(supplierId: string, positionId: string, rowIdx: number, field: 'pricePerUnit' | 'quantity', value: string) => {
|
||||
if (isReadOnly) return;
|
||||
@@ -830,7 +809,6 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
supplierCurrencies,
|
||||
computedOffers,
|
||||
minTotalByPosition,
|
||||
recommendedSupplierId,
|
||||
// handlers
|
||||
updateEdit,
|
||||
updatePriceKzt,
|
||||
|
||||
@@ -54,9 +54,40 @@ export interface Supplier {
|
||||
spId?: number; // SP item ID in Поставщики (populated after saveSupplierWithOffer)
|
||||
name: string;
|
||||
inn?: string; // UI only — not stored in SP
|
||||
bin?: string; // БИН из флоу (Adata) — хранится в SP-поле 'bin'
|
||||
hasOffer?: boolean; // determines target list: Поставщики vs Не получены КП
|
||||
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
|
||||
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 {
|
||||
@@ -77,6 +108,7 @@ export interface FlowResult {
|
||||
price?: number;
|
||||
quantity?: number;
|
||||
supplier?: string;
|
||||
supplierBIN?: string;
|
||||
}
|
||||
|
||||
export interface CurrencyRate {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ApprovalMemberStatus,
|
||||
SyntexFields,
|
||||
FlowResult,
|
||||
AdataCompanyInfo,
|
||||
} from "../components/types/domain";
|
||||
|
||||
// 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 ADATA_POLL_INTERVAL_MS = 5000;
|
||||
const ADATA_MAX_POLL_RETRIES = 18; // 18 * 5s = 90s — макс. время по документации Adata
|
||||
const FLOW_URL =
|
||||
"https://eab3d2815f2beb42bf2e7ec1731708.92.environment.api.powerplatform.com/powerautomate/automations/direct/workflows/60ec47fdba7c453ea72bfed57bcfa3a2/triggers/manual/paths/invoke?api-version=1";
|
||||
const AI_COMMENT_FLOW_URL =
|
||||
@@ -1162,6 +1165,173 @@ export class CtService {
|
||||
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(
|
||||
name: string,
|
||||
): Promise<{ spId: number; offerId: number }> {
|
||||
@@ -1418,6 +1588,10 @@ export class CtService {
|
||||
: undefined,
|
||||
supplier:
|
||||
parsed.supplier !== undefined ? String(parsed.supplier) : undefined,
|
||||
supplierBIN:
|
||||
parsed.supplierBIN !== undefined
|
||||
? String(parsed.supplierBIN)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user